feat: add Claude usage tracking via CLI

Adds a Claude usage tracking feature that displays session, weekly, and Sonnet usage stats. Uses the Claude CLI's /usage command to fetch data (no API key required).

Features:
- Usage popover in board header showing session, weekly, and Sonnet limits
- Progress bars with color-coded status (green/orange/red)
- Auto-refresh with configurable interval
- Caching of usage data with stale indicator
- Settings section for refresh interval configuration

Server:
- ClaudeUsageService: Executes Claude CLI via PTY (expect) to fetch usage
- New /api/claude/usage endpoint

UI:
- ClaudeUsagePopover component with usage cards
- ClaudeUsageSection in settings for configuration
- Integration with app store for persistence
This commit is contained in:
Mohamad Yahia
2025-12-21 08:03:43 +04:00
parent 5aedb4fadf
commit 5bd2b705dc
11 changed files with 952 additions and 5 deletions

View File

@@ -0,0 +1,309 @@
import { useState, useEffect, useMemo } from "react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import {
RefreshCw,
AlertTriangle,
CheckCircle,
XCircle,
Clock,
ExternalLink,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { getElectronAPI } from "@/lib/electron";
import { useAppStore } from "@/store/app-store";
export function ClaudeUsagePopover() {
const { claudeRefreshInterval, claudeUsage, claudeUsageLastUpdated, setClaudeUsage } =
useAppStore();
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Check if data is stale (older than 2 minutes) - recalculates when claudeUsageLastUpdated changes
const isStale = useMemo(() => {
return !claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > 2 * 60 * 1000;
}, [claudeUsageLastUpdated]);
const fetchUsage = async (isAutoRefresh = false) => {
if (!isAutoRefresh) setLoading(true);
setError(null);
try {
const api = getElectronAPI();
if (!api.claude) {
throw new Error("Claude API not available");
}
const data = await api.claude.getUsage();
if (data.error) {
throw new Error(data.message || data.error);
}
setClaudeUsage(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to fetch usage");
} finally {
if (!isAutoRefresh) setLoading(false);
}
};
// Auto-fetch on mount if data is stale
useEffect(() => {
if (isStale) {
fetchUsage(true);
}
}, []);
useEffect(() => {
// Initial fetch when opened
if (open) {
if (!claudeUsage) {
fetchUsage();
} else {
const now = Date.now();
const stale = !claudeUsageLastUpdated || now - claudeUsageLastUpdated > 2 * 60 * 1000;
if (stale) {
fetchUsage(false);
}
}
}
// Auto-refresh interval (only when open)
let intervalId: NodeJS.Timeout | null = null;
if (open && claudeRefreshInterval > 0) {
intervalId = setInterval(() => {
fetchUsage(true);
}, claudeRefreshInterval * 1000);
}
return () => {
if (intervalId) clearInterval(intervalId);
};
}, [open]);
// Derived status color/icon helper
const getStatusInfo = (percentage: number) => {
if (percentage >= 80)
return { color: "text-red-500", icon: XCircle, bg: "bg-red-500" };
if (percentage >= 50)
return { color: "text-orange-500", icon: AlertTriangle, bg: "bg-orange-500" };
return { color: "text-green-500", icon: CheckCircle, bg: "bg-green-500" };
};
// Helper component for the progress bar
const ProgressBar = ({
percentage,
colorClass,
}: {
percentage: number;
colorClass: string;
}) => (
<div className="h-2 w-full bg-secondary/50 rounded-full overflow-hidden">
<div
className={cn("h-full transition-all duration-500", colorClass)}
style={{ width: `${Math.min(percentage, 100)}%` }}
/>
</div>
);
const UsageCard = ({
title,
subtitle,
percentage,
resetText,
isPrimary = false,
stale = false,
}: {
title: string;
subtitle: string;
percentage: number;
resetText?: string;
isPrimary?: boolean;
stale?: boolean;
}) => {
const status = getStatusInfo(percentage);
const StatusIcon = status.icon;
return (
<div
className={cn(
"rounded-xl border bg-card/50 p-4 transition-opacity",
isPrimary ? "border-border/60 shadow-sm" : "border-border/40",
stale && "opacity-60"
)}
>
<div className="flex items-start justify-between mb-3">
<div>
<h4 className={cn("font-semibold", isPrimary ? "text-sm" : "text-xs")}>
{title}
</h4>
<p className="text-[10px] text-muted-foreground">{subtitle}</p>
</div>
<div className="flex items-center gap-1.5">
<StatusIcon className={cn("w-3.5 h-3.5", status.color)} />
<span
className={cn(
"font-mono font-bold",
status.color,
isPrimary ? "text-base" : "text-sm"
)}
>
{Math.round(percentage)}%
</span>
</div>
</div>
<ProgressBar percentage={percentage} colorClass={status.bg} />
{resetText && (
<div className="mt-2 flex justify-end">
<p className="text-xs text-muted-foreground flex items-center gap-1">
<Clock className="w-3 h-3" />
{resetText}
</p>
</div>
)}
</div>
);
};
// Header Button
const maxPercentage = claudeUsage
? Math.max(claudeUsage.sessionPercentage || 0, claudeUsage.weeklyPercentage || 0)
: 0;
const getProgressBarColor = (percentage: number) => {
if (percentage >= 100) return "bg-red-500";
if (percentage >= 75) return "bg-yellow-500";
return "bg-green-500";
};
const trigger = (
<Button
variant="ghost"
size="sm"
className="h-9 gap-3 bg-secondary border border-border px-3"
>
<span className="text-sm font-medium">Usage</span>
{claudeUsage && (
<div className={cn(
"h-1.5 w-16 bg-muted-foreground/20 rounded-full overflow-hidden transition-opacity",
isStale && "opacity-60"
)}>
<div
className={cn(
"h-full transition-all duration-500",
getProgressBarColor(maxPercentage)
)}
style={{ width: `${Math.min(maxPercentage, 100)}%` }}
/>
</div>
)}
</Button>
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent
className="w-80 p-0 overflow-hidden bg-background/95 backdrop-blur-xl border-border shadow-2xl"
align="end"
sideOffset={8}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border/50 bg-secondary/10">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold">Claude Usage</span>
</div>
<Button
variant="ghost"
size="icon"
className={cn("h-6 w-6", loading && "opacity-80")}
onClick={() => !loading && fetchUsage(false)}
>
<RefreshCw className="w-3.5 h-3.5" />
</Button>
</div>
{/* Content */}
<div className="p-4 space-y-4">
{error ? (
<div className="flex flex-col items-center justify-center py-6 text-center space-y-3">
<AlertTriangle className="w-8 h-8 text-yellow-500/80" />
<div className="space-y-1 flex flex-col items-center">
<p className="text-sm font-medium">{error}</p>
<p className="text-xs text-muted-foreground">
Make sure Claude CLI is installed and authenticated via <code className="font-mono bg-muted px-1 rounded">claude login</code>
</p>
</div>
</div>
) : !claudeUsage ? (
// Loading state
<div className="flex flex-col items-center justify-center py-8 space-y-2">
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground/50" />
<p className="text-xs text-muted-foreground">Loading usage data...</p>
</div>
) : (
<>
{/* Primary Card */}
<UsageCard
title="Session Usage"
subtitle="5-hour rolling window"
percentage={claudeUsage.sessionPercentage}
resetText={claudeUsage.sessionResetText}
isPrimary={true}
stale={isStale}
/>
{/* Secondary Cards Grid */}
<div className="grid grid-cols-2 gap-3">
<UsageCard
title="Weekly"
subtitle="All models"
percentage={claudeUsage.weeklyPercentage}
resetText={claudeUsage.weeklyResetText}
stale={isStale}
/>
<UsageCard
title="Sonnet"
subtitle="Weekly"
percentage={claudeUsage.opusWeeklyPercentage}
resetText={claudeUsage.opusResetText}
stale={isStale}
/>
</div>
{/* Extra Usage / Cost */}
{claudeUsage.costLimit && claudeUsage.costLimit > 0 && (
<UsageCard
title="Extra Usage"
subtitle={`${claudeUsage.costUsed ?? 0} / ${claudeUsage.costLimit} ${claudeUsage.costCurrency ?? ""}`}
percentage={
claudeUsage.costLimit > 0
? ((claudeUsage.costUsed ?? 0) / claudeUsage.costLimit) * 100
: 0
}
stale={isStale}
/>
)}
</>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between px-4 py-2 bg-secondary/10 border-t border-border/50">
<a
href="https://status.claude.com"
target="_blank"
rel="noreferrer"
className="text-[10px] text-muted-foreground hover:text-foreground flex items-center gap-1 transition-colors"
>
Claude Status <ExternalLink className="w-2.5 h-2.5" />
</a>
<div className="flex gap-2">{/* Could add quick settings link here */}</div>
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -6,6 +6,7 @@ import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Plus, Bot } from "lucide-react";
import { KeyboardShortcut } from "@/hooks/use-keyboard-shortcuts";
import { ClaudeUsagePopover } from "@/components/claude-usage-popover";
interface BoardHeaderProps {
projectName: string;
@@ -37,6 +38,9 @@ export function BoardHeader({
<p className="text-sm text-muted-foreground">{projectName}</p>
</div>
<div className="flex gap-2 items-center">
{/* Usage Popover */}
{isMounted && <ClaudeUsagePopover />}
{/* Concurrency Slider - only show after mount to prevent hydration issues */}
{isMounted && (
<div

View File

@@ -9,6 +9,7 @@ import { KeyboardMapDialog } from "./settings-view/components/keyboard-map-dialo
import { DeleteProjectDialog } from "./settings-view/components/delete-project-dialog";
import { SettingsNavigation } from "./settings-view/components/settings-navigation";
import { ApiKeysSection } from "./settings-view/api-keys/api-keys-section";
import { ClaudeUsageSection } from "./settings-view/api-keys/claude-usage-section";
import { ClaudeCliStatus } from "./settings-view/cli-status/claude-cli-status";
import { AIEnhancementSection } from "./settings-view/ai-enhancement";
import { AppearanceSection } from "./settings-view/appearance/appearance-section";
@@ -92,11 +93,14 @@ export function SettingsView() {
switch (activeView) {
case "claude":
return (
<ClaudeCliStatus
status={claudeCliStatus}
isChecking={isCheckingClaudeCli}
onRefresh={handleRefreshClaudeCli}
/>
<div className="space-y-6">
<ClaudeCliStatus
status={claudeCliStatus}
isChecking={isCheckingClaudeCli}
onRefresh={handleRefreshClaudeCli}
/>
<ClaudeUsageSection />
</div>
);
case "ai-enhancement":
return <AIEnhancementSection />;

View File

@@ -0,0 +1,75 @@
import { Clock } from "lucide-react";
import { cn } from "@/lib/utils";
import { useState, useEffect } from "react";
import { Slider } from "@/components/ui/slider";
import { useAppStore } from "@/store/app-store";
export function ClaudeUsageSection() {
const { claudeRefreshInterval, setClaudeRefreshInterval } = useAppStore();
const [localInterval, setLocalInterval] = useState(claudeRefreshInterval);
// Sync local state with store when store changes (e.g. initial load)
useEffect(() => {
setLocalInterval(claudeRefreshInterval);
}, [claudeRefreshInterval]);
return (
<div
className={cn(
"rounded-2xl overflow-hidden",
"border border-border/50",
"bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl",
"shadow-sm shadow-black/5"
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-green-500/20 to-green-600/10 flex items-center justify-center border border-green-500/20">
<div className="w-5 h-5 rounded-full bg-green-500/50" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">Claude Usage Tracking</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Track your Claude Code usage limits. Uses the Claude CLI for data.
</p>
</div>
<div className="p-6 space-y-6">
{/* Info about CLI requirement */}
<div className="rounded-lg bg-secondary/30 p-3 text-xs text-muted-foreground space-y-2 border border-border/50">
<p>Usage tracking requires Claude Code CLI to be installed and authenticated:</p>
<ol className="list-decimal list-inside space-y-1 ml-1">
<li>Install Claude Code CLI if not already installed</li>
<li>Run <code className="font-mono bg-muted px-1 rounded">claude login</code> to authenticate</li>
<li>Usage data will be fetched automatically</li>
</ol>
</div>
{/* Refresh Interval Section */}
<div className="space-y-4">
<div className="space-y-1">
<h3 className="text-sm font-medium text-foreground flex items-center gap-2">
<Clock className="w-4 h-4 text-muted-foreground" />
Refresh Interval
</h3>
<p className="text-xs text-muted-foreground">
How often to check for usage updates.
</p>
</div>
<div className="flex items-center gap-4">
<Slider
value={[Math.max(30, Math.min(120, localInterval || 30))]}
onValueChange={(vals) => setLocalInterval(vals[0])}
onValueCommit={(vals) => setClaudeRefreshInterval(vals[0])}
min={30}
max={120}
step={5}
className="flex-1"
/>
<span className="w-12 text-sm font-mono text-right">{Math.max(30, Math.min(120, localInterval || 30))}s</span>
</div>
</div>
</div>
</div>
);
}

View File

@@ -482,6 +482,9 @@ export interface ElectronAPI {
sessionId: string
) => Promise<{ success: boolean; error?: string }>;
};
claude?: {
getUsage: () => Promise<any>;
};
}
// Note: Window interface is declared in @/types/electron.d.ts
@@ -879,6 +882,33 @@ const getMockElectronAPI = (): ElectronAPI => {
// Mock Running Agents API
runningAgents: createMockRunningAgentsAPI(),
// Mock Claude API
claude: {
getUsage: async () => {
console.log("[Mock] Getting Claude usage");
return {
sessionTokensUsed: 0,
sessionLimit: 0,
sessionPercentage: 15,
sessionResetTime: new Date(Date.now() + 3600000).toISOString(),
sessionResetText: "Resets in 1h",
weeklyTokensUsed: 0,
weeklyLimit: 0,
weeklyPercentage: 5,
weeklyResetTime: new Date(Date.now() + 86400000 * 2).toISOString(),
weeklyResetText: "Resets Dec 23",
opusWeeklyTokensUsed: 0,
opusWeeklyPercentage: 1,
opusResetText: "Resets Dec 27",
costUsed: null,
costLimit: null,
costCurrency: null,
lastUpdated: new Date().toISOString(),
userTimezone: "UTC"
};
},
}
};
};

View File

@@ -1016,6 +1016,11 @@ export class HttpApiClient implements ElectronAPI {
): Promise<{ success: boolean; error?: string }> =>
this.httpDelete(`/api/sessions/${sessionId}`),
};
// Claude API
claude = {
getUsage: (): Promise<any> => this.get("/api/claude/usage"),
};
}
// Singleton instance

View File

@@ -507,6 +507,67 @@ export interface AppState {
planContent: string;
planningMode: "lite" | "spec" | "full";
} | null;
// Claude Usage Tracking
claudeRefreshInterval: number; // Refresh interval in seconds (default: 60)
claudeUsage: ClaudeUsage | null;
claudeUsageLastUpdated: number | null;
}
// Claude Usage interface matching the server response
export interface ClaudeUsage {
sessionTokensUsed: number;
sessionLimit: number;
sessionPercentage: number;
sessionResetTime: string;
sessionResetText: string;
weeklyTokensUsed: number;
weeklyLimit: number;
weeklyPercentage: number;
weeklyResetTime: string;
weeklyResetText: string;
opusWeeklyTokensUsed: number;
opusWeeklyPercentage: number;
opusResetText: string;
costUsed: number | null;
costLimit: number | null;
costCurrency: string | null;
}
/**
* Check if Claude usage is at its limit (any of: session >= 100%, weekly >= 100%, OR cost >= limit)
* Returns true if any limit is reached, meaning auto mode should pause feature pickup.
*/
export function isClaudeUsageAtLimit(claudeUsage: ClaudeUsage | null): boolean {
if (!claudeUsage) {
// No usage data available - don't block
return false;
}
// Check session limit (5-hour window)
if (claudeUsage.sessionPercentage >= 100) {
return true;
}
// Check weekly limit
if (claudeUsage.weeklyPercentage >= 100) {
return true;
}
// Check cost limit (if configured)
if (
claudeUsage.costLimit !== null &&
claudeUsage.costLimit > 0 &&
claudeUsage.costUsed !== null &&
claudeUsage.costUsed >= claudeUsage.costLimit
) {
return true;
}
return false;
}
// Default background settings for board backgrounds
@@ -756,6 +817,11 @@ export interface AppActions {
planningMode: "lite" | "spec" | "full";
} | null) => void;
// Claude Usage Tracking actions
setClaudeRefreshInterval: (interval: number) => void;
setClaudeUsageLastUpdated: (timestamp: number) => void;
setClaudeUsage: (usage: ClaudeUsage | null) => void;
// Reset
reset: () => void;
}
@@ -848,6 +914,9 @@ const initialState: AppState = {
defaultRequirePlanApproval: false,
defaultAIProfileId: null,
pendingPlanApproval: null,
claudeRefreshInterval: 60,
claudeUsage: null,
claudeUsageLastUpdated: null,
};
export const useAppStore = create<AppState & AppActions>()(
@@ -2280,6 +2349,14 @@ export const useAppStore = create<AppState & AppActions>()(
// Plan Approval actions
setPendingPlanApproval: (approval) => set({ pendingPlanApproval: approval }),
// Claude Usage Tracking actions
setClaudeRefreshInterval: (interval: number) => set({ claudeRefreshInterval: interval }),
setClaudeUsageLastUpdated: (timestamp: number) => set({ claudeUsageLastUpdated: timestamp }),
setClaudeUsage: (usage: ClaudeUsage | null) => set({
claudeUsage: usage,
claudeUsageLastUpdated: usage ? Date.now() : null,
}),
// Reset
reset: () => set(initialState),
}),
@@ -2352,6 +2429,10 @@ export const useAppStore = create<AppState & AppActions>()(
defaultPlanningMode: state.defaultPlanningMode,
defaultRequirePlanApproval: state.defaultRequirePlanApproval,
defaultAIProfileId: state.defaultAIProfileId,
// Claude usage tracking
claudeUsage: state.claudeUsage,
claudeUsageLastUpdated: state.claudeUsageLastUpdated,
claudeRefreshInterval: state.claudeRefreshInterval,
}),
}
)