diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index ca148de61..adf39f3a5 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -44,6 +44,8 @@ import { AutoModeService } from "./services/auto-mode-service.js"; import { getTerminalService } from "./services/terminal-service.js"; import { SettingsService } from "./services/settings-service.js"; import { createSpecRegenerationRoutes } from "./routes/app-spec/index.js"; +import { createClaudeRoutes } from "./routes/claude/index.js"; +import { ClaudeUsageService } from "./services/claude-usage-service.js"; // Load environment variables dotenv.config(); @@ -111,6 +113,7 @@ const agentService = new AgentService(DATA_DIR, events); const featureLoader = new FeatureLoader(); const autoModeService = new AutoModeService(events); const settingsService = new SettingsService(DATA_DIR); +const claudeUsageService = new ClaudeUsageService(); // Initialize services (async () => { @@ -141,6 +144,7 @@ app.use("/api/workspace", createWorkspaceRoutes()); app.use("/api/templates", createTemplatesRoutes()); app.use("/api/terminal", createTerminalRoutes()); app.use("/api/settings", createSettingsRoutes(settingsService)); +app.use("/api/claude", createClaudeRoutes(claudeUsageService)); // Create HTTP server const server = createServer(app); diff --git a/apps/server/src/routes/claude/index.ts b/apps/server/src/routes/claude/index.ts new file mode 100644 index 000000000..f951aa34e --- /dev/null +++ b/apps/server/src/routes/claude/index.ts @@ -0,0 +1,43 @@ +import { Router, Request, Response } from "express"; +import { ClaudeUsageService } from "../../services/claude-usage-service.js"; + +export function createClaudeRoutes(service: ClaudeUsageService): Router { + const router = Router(); + + // Get current usage (fetches from Claude CLI) + router.get("/usage", async (req: Request, res: Response) => { + try { + // Check if Claude CLI is available first + const isAvailable = await service.isAvailable(); + if (!isAvailable) { + res.status(503).json({ + error: "Claude CLI not found", + message: "Please install Claude Code CLI and run 'claude login' to authenticate" + }); + return; + } + + const usage = await service.fetchUsageData(); + res.json(usage); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + + if (message.includes("Authentication required") || message.includes("token_expired")) { + res.status(401).json({ + error: "Authentication required", + message: "Please run 'claude login' to authenticate" + }); + } else if (message.includes("timed out")) { + res.status(504).json({ + error: "Command timed out", + message: "The Claude CLI took too long to respond" + }); + } else { + console.error("Error fetching usage:", error); + res.status(500).json({ error: message }); + } + } + }); + + return router; +} diff --git a/apps/server/src/routes/claude/types.ts b/apps/server/src/routes/claude/types.ts new file mode 100644 index 000000000..2f6eb5974 --- /dev/null +++ b/apps/server/src/routes/claude/types.ts @@ -0,0 +1,35 @@ +/** + * Claude Usage types for CLI-based usage tracking + */ + +export type ClaudeUsage = { + sessionTokensUsed: number; + sessionLimit: number; + sessionPercentage: number; + sessionResetTime: string; // ISO date string + sessionResetText: string; // Raw text like "Resets 10:59am (Asia/Dubai)" + + weeklyTokensUsed: number; + weeklyLimit: number; + weeklyPercentage: number; + weeklyResetTime: string; // ISO date string + weeklyResetText: string; // Raw text like "Resets Dec 22 at 7:59pm (Asia/Dubai)" + + sonnetWeeklyTokensUsed: number; + sonnetWeeklyPercentage: number; + sonnetResetText: string; // Raw text like "Resets Dec 27 at 9:59am (Asia/Dubai)" + + costUsed: number | null; + costLimit: number | null; + costCurrency: string | null; + + lastUpdated: string; // ISO date string + userTimezone: string; +}; + +export type ClaudeStatus = { + indicator: { + color: "green" | "yellow" | "orange" | "red" | "gray"; + }; + description: string; +}; diff --git a/apps/server/src/services/claude-usage-service.ts b/apps/server/src/services/claude-usage-service.ts new file mode 100644 index 000000000..409437b65 --- /dev/null +++ b/apps/server/src/services/claude-usage-service.ts @@ -0,0 +1,452 @@ +import { spawn } from "child_process"; +import * as os from "os"; +import * as pty from "node-pty"; +import { ClaudeUsage } from "../routes/claude/types.js"; + +/** + * Claude Usage Service + * + * Fetches usage data by executing the Claude CLI's /usage command. + * This approach doesn't require any API keys - it relies on the user + * having already authenticated via `claude login`. + * + * Platform-specific implementations: + * - macOS: Uses 'expect' command for PTY + * - Windows: Uses node-pty for PTY + */ +export class ClaudeUsageService { + private claudeBinary = "claude"; + private timeout = 30000; // 30 second timeout + private isWindows = os.platform() === "win32"; + + /** + * Check if Claude CLI is available on the system + */ + async isAvailable(): Promise { + return new Promise((resolve) => { + const checkCmd = this.isWindows ? "where" : "which"; + const proc = spawn(checkCmd, [this.claudeBinary]); + proc.on("close", (code) => { + resolve(code === 0); + }); + proc.on("error", () => { + resolve(false); + }); + }); + } + + /** + * Fetch usage data by executing the Claude CLI + */ + async fetchUsageData(): Promise { + const output = await this.executeClaudeUsageCommand(); + return this.parseUsageOutput(output); + } + + /** + * Execute the claude /usage command and return the output + * Uses platform-specific PTY implementation + */ + private executeClaudeUsageCommand(): Promise { + if (this.isWindows) { + return this.executeClaudeUsageCommandWindows(); + } + return this.executeClaudeUsageCommandMac(); + } + + /** + * macOS implementation using 'expect' command + */ + private executeClaudeUsageCommandMac(): Promise { + return new Promise((resolve, reject) => { + let stdout = ""; + let stderr = ""; + let settled = false; + + // Use a simple working directory (home or tmp) + const workingDirectory = process.env.HOME || "/tmp"; + + // Use 'expect' with an inline script to run claude /usage with a PTY + // Wait for "Current session" header, then wait for full output before exiting + const expectScript = ` + set timeout 20 + spawn claude /usage + expect { + "Current session" { + sleep 2 + send "\\x1b" + } + "Esc to cancel" { + sleep 3 + send "\\x1b" + } + timeout {} + eof {} + } + expect eof + `; + + const proc = spawn("expect", ["-c", expectScript], { + cwd: workingDirectory, + env: { + ...process.env, + TERM: "xterm-256color", + }, + }); + + const timeoutId = setTimeout(() => { + if (!settled) { + settled = true; + proc.kill(); + reject(new Error("Command timed out")); + } + }, this.timeout); + + proc.stdout.on("data", (data) => { + stdout += data.toString(); + }); + + proc.stderr.on("data", (data) => { + stderr += data.toString(); + }); + + proc.on("close", (code) => { + clearTimeout(timeoutId); + if (settled) return; + settled = true; + + // Check for authentication errors in output + if (stdout.includes("token_expired") || stdout.includes("authentication_error") || + stderr.includes("token_expired") || stderr.includes("authentication_error")) { + reject(new Error("Authentication required - please run 'claude login'")); + return; + } + + // Even if exit code is non-zero, we might have useful output + if (stdout.trim()) { + resolve(stdout); + } else if (code !== 0) { + reject(new Error(stderr || `Command exited with code ${code}`)); + } else { + reject(new Error("No output from claude command")); + } + }); + + proc.on("error", (err) => { + clearTimeout(timeoutId); + if (!settled) { + settled = true; + reject(new Error(`Failed to execute claude: ${err.message}`)); + } + }); + }); + } + + /** + * Windows implementation using node-pty + */ + private executeClaudeUsageCommandWindows(): Promise { + return new Promise((resolve, reject) => { + let output = ""; + let settled = false; + let hasSeenUsageData = false; + + const workingDirectory = process.env.USERPROFILE || os.homedir() || "C:\\"; + + const ptyProcess = pty.spawn("cmd.exe", ["/c", "claude", "/usage"], { + name: "xterm-256color", + cols: 120, + rows: 30, + cwd: workingDirectory, + env: { + ...process.env, + TERM: "xterm-256color", + } as Record, + }); + + const timeoutId = setTimeout(() => { + if (!settled) { + settled = true; + ptyProcess.kill(); + reject(new Error("Command timed out")); + } + }, this.timeout); + + ptyProcess.onData((data) => { + output += data; + + // Check if we've seen the usage data (look for "Current session") + if (!hasSeenUsageData && output.includes("Current session")) { + hasSeenUsageData = true; + // Wait for full output, then send escape to exit + setTimeout(() => { + if (!settled) { + ptyProcess.write("\x1b"); // Send escape key + } + }, 2000); + } + + // Fallback: if we see "Esc to cancel" but haven't seen usage data yet + if (!hasSeenUsageData && output.includes("Esc to cancel")) { + setTimeout(() => { + if (!settled) { + ptyProcess.write("\x1b"); // Send escape key + } + }, 3000); + } + }); + + ptyProcess.onExit(({ exitCode }) => { + clearTimeout(timeoutId); + if (settled) return; + settled = true; + + // Check for authentication errors in output + if (output.includes("token_expired") || output.includes("authentication_error")) { + reject(new Error("Authentication required - please run 'claude login'")); + return; + } + + if (output.trim()) { + resolve(output); + } else if (exitCode !== 0) { + reject(new Error(`Command exited with code ${exitCode}`)); + } else { + reject(new Error("No output from claude command")); + } + }); + }); + } + + /** + * Strip ANSI escape codes from text + */ + private stripAnsiCodes(text: string): string { + // eslint-disable-next-line no-control-regex + return text.replace(/\x1B\[[0-9;]*[A-Za-z]/g, ""); + } + + /** + * Parse the Claude CLI output to extract usage information + * + * Expected output format: + * ``` + * Claude Code v1.0.27 + * + * Current session + * ████████████████░░░░ 65% left + * Resets in 2h 15m + * + * Current week (all models) + * ██████████░░░░░░░░░░ 35% left + * Resets Jan 15, 3:30pm (America/Los_Angeles) + * + * Current week (Opus) + * ████████████████████ 80% left + * Resets Jan 15, 3:30pm (America/Los_Angeles) + * ``` + */ + private parseUsageOutput(rawOutput: string): ClaudeUsage { + const output = this.stripAnsiCodes(rawOutput); + const lines = output.split("\n").map(l => l.trim()).filter(l => l); + + // Parse session usage + const sessionData = this.parseSection(lines, "Current session", "session"); + + // Parse weekly usage (all models) + const weeklyData = this.parseSection(lines, "Current week (all models)", "weekly"); + + // Parse Sonnet/Opus usage - try different labels + let sonnetData = this.parseSection(lines, "Current week (Sonnet only)", "sonnet"); + if (sonnetData.percentage === 0) { + sonnetData = this.parseSection(lines, "Current week (Sonnet)", "sonnet"); + } + if (sonnetData.percentage === 0) { + sonnetData = this.parseSection(lines, "Current week (Opus)", "sonnet"); + } + + return { + sessionTokensUsed: 0, // Not available from CLI + sessionLimit: 0, // Not available from CLI + sessionPercentage: sessionData.percentage, + sessionResetTime: sessionData.resetTime, + sessionResetText: sessionData.resetText, + + weeklyTokensUsed: 0, // Not available from CLI + weeklyLimit: 0, // Not available from CLI + weeklyPercentage: weeklyData.percentage, + weeklyResetTime: weeklyData.resetTime, + weeklyResetText: weeklyData.resetText, + + sonnetWeeklyTokensUsed: 0, // Not available from CLI + sonnetWeeklyPercentage: sonnetData.percentage, + sonnetResetText: sonnetData.resetText, + + costUsed: null, // Not available from CLI + costLimit: null, + costCurrency: null, + + lastUpdated: new Date().toISOString(), + userTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }; + } + + /** + * Parse a section of the usage output to extract percentage and reset time + */ + private parseSection(lines: string[], sectionLabel: string, type: string): { percentage: number; resetTime: string; resetText: string } { + let percentage = 0; + let resetTime = this.getDefaultResetTime(type); + let resetText = ""; + + // Find the LAST occurrence of the section (terminal output has multiple screen refreshes) + let sectionIndex = -1; + for (let i = lines.length - 1; i >= 0; i--) { + if (lines[i].toLowerCase().includes(sectionLabel.toLowerCase())) { + sectionIndex = i; + break; + } + } + + if (sectionIndex === -1) { + return { percentage, resetTime, resetText }; + } + + // Look at the lines following the section header (within a window of 5 lines) + const searchWindow = lines.slice(sectionIndex, sectionIndex + 5); + + for (const line of searchWindow) { + // Extract percentage - only take the first match (avoid picking up next section's data) + if (percentage === 0) { + const percentMatch = line.match(/(\d{1,3})\s*%\s*(left|used|remaining)/i); + if (percentMatch) { + const value = parseInt(percentMatch[1], 10); + const isUsed = percentMatch[2].toLowerCase() === "used"; + // Convert "left" to "used" percentage (our UI shows % used) + percentage = isUsed ? value : (100 - value); + } + } + + // Extract reset time - only take the first match + if (!resetText && line.toLowerCase().includes("reset")) { + resetText = line; + } + } + + // Parse the reset time if we found one + if (resetText) { + resetTime = this.parseResetTime(resetText, type); + // Strip timezone like "(Asia/Dubai)" from the display text + resetText = resetText.replace(/\s*\([A-Za-z_\/]+\)\s*$/, "").trim(); + } + + return { percentage, resetTime, resetText }; + } + + /** + * Parse reset time from text like "Resets in 2h 15m", "Resets 11am", or "Resets Dec 22 at 8pm" + */ + private parseResetTime(text: string, type: string): string { + const now = new Date(); + + // Try to parse duration format: "Resets in 2h 15m" or "Resets in 30m" + const durationMatch = text.match(/(\d+)\s*h(?:ours?)?(?:\s+(\d+)\s*m(?:in)?)?|(\d+)\s*m(?:in)?/i); + if (durationMatch) { + let hours = 0; + let minutes = 0; + + if (durationMatch[1]) { + hours = parseInt(durationMatch[1], 10); + minutes = durationMatch[2] ? parseInt(durationMatch[2], 10) : 0; + } else if (durationMatch[3]) { + minutes = parseInt(durationMatch[3], 10); + } + + const resetDate = new Date(now.getTime() + (hours * 60 + minutes) * 60 * 1000); + return resetDate.toISOString(); + } + + // Try to parse simple time-only format: "Resets 11am" or "Resets 3pm" + const simpleTimeMatch = text.match(/resets\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i); + if (simpleTimeMatch) { + let hours = parseInt(simpleTimeMatch[1], 10); + const minutes = simpleTimeMatch[2] ? parseInt(simpleTimeMatch[2], 10) : 0; + const ampm = simpleTimeMatch[3].toLowerCase(); + + // Convert 12-hour to 24-hour + if (ampm === "pm" && hours !== 12) { + hours += 12; + } else if (ampm === "am" && hours === 12) { + hours = 0; + } + + // Create date for today at specified time + const resetDate = new Date(now); + resetDate.setHours(hours, minutes, 0, 0); + + // If time has passed, use tomorrow + if (resetDate <= now) { + resetDate.setDate(resetDate.getDate() + 1); + } + return resetDate.toISOString(); + } + + // Try to parse date format: "Resets Dec 22 at 8pm" or "Resets Jan 15, 3:30pm" + const dateMatch = text.match(/([A-Za-z]{3,})\s+(\d{1,2})(?:\s+at\s+|\s*,?\s*)(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i); + if (dateMatch) { + const monthName = dateMatch[1]; + const day = parseInt(dateMatch[2], 10); + let hours = parseInt(dateMatch[3], 10); + const minutes = dateMatch[4] ? parseInt(dateMatch[4], 10) : 0; + const ampm = dateMatch[5].toLowerCase(); + + // Convert 12-hour to 24-hour + if (ampm === "pm" && hours !== 12) { + hours += 12; + } else if (ampm === "am" && hours === 12) { + hours = 0; + } + + // Parse month name + const months: Record = { + jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, + jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11 + }; + const month = months[monthName.toLowerCase().substring(0, 3)]; + + if (month !== undefined) { + let year = now.getFullYear(); + // If the date appears to be in the past, assume next year + const resetDate = new Date(year, month, day, hours, minutes); + if (resetDate < now) { + resetDate.setFullYear(year + 1); + } + return resetDate.toISOString(); + } + } + + // Fallback to default + return this.getDefaultResetTime(type); + } + + /** + * Get default reset time based on usage type + */ + private getDefaultResetTime(type: string): string { + const now = new Date(); + + if (type === "session") { + // Session resets in ~5 hours + return new Date(now.getTime() + 5 * 60 * 60 * 1000).toISOString(); + } else { + // Weekly resets on next Monday around noon + const result = new Date(now); + const currentDay = now.getDay(); + let daysUntilMonday = (1 + 7 - currentDay) % 7; + if (daysUntilMonday === 0) daysUntilMonday = 7; + result.setDate(result.getDate() + daysUntilMonday); + result.setHours(12, 59, 0, 0); + return result.toISOString(); + } + } +} diff --git a/apps/ui/src/components/claude-usage-popover.tsx b/apps/ui/src/components/claude-usage-popover.tsx new file mode 100644 index 000000000..625ce8f20 --- /dev/null +++ b/apps/ui/src/components/claude-usage-popover.tsx @@ -0,0 +1,349 @@ +import { useState, useEffect, useMemo, useCallback } 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"; + +// Error codes for distinguishing failure modes +const ERROR_CODES = { + API_BRIDGE_UNAVAILABLE: "API_BRIDGE_UNAVAILABLE", + AUTH_ERROR: "AUTH_ERROR", + UNKNOWN: "UNKNOWN", +} as const; + +type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES]; + +type UsageError = { + code: ErrorCode; + message: string; +}; + +// Fixed refresh interval (45 seconds) +const REFRESH_INTERVAL_SECONDS = 45; + +export function ClaudeUsagePopover() { + const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore(); + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 = useCallback( + async (isAutoRefresh = false) => { + if (!isAutoRefresh) setLoading(true); + setError(null); + try { + const api = getElectronAPI(); + if (!api.claude) { + setError({ + code: ERROR_CODES.API_BRIDGE_UNAVAILABLE, + message: 'Claude API bridge not available', + }); + return; + } + const data = await api.claude.getUsage(); + if ('error' in data) { + setError({ + code: ERROR_CODES.AUTH_ERROR, + message: data.message || data.error, + }); + return; + } + setClaudeUsage(data); + } catch (err) { + setError({ + code: ERROR_CODES.UNKNOWN, + message: err instanceof Error ? err.message : 'Failed to fetch usage', + }); + } finally { + if (!isAutoRefresh) setLoading(false); + } + }, + [setClaudeUsage] + ); + + // Auto-fetch on mount if data is stale + useEffect(() => { + if (isStale) { + fetchUsage(true); + } + }, [isStale, fetchUsage]); + + useEffect(() => { + // Initial fetch when opened + if (open) { + if (!claudeUsage || isStale) { + fetchUsage(); + } + } + + // Auto-refresh interval (only when open) + let intervalId: NodeJS.Timeout | null = null; + if (open) { + intervalId = setInterval(() => { + fetchUsage(true); + }, REFRESH_INTERVAL_SECONDS * 1000); + } + + return () => { + if (intervalId) clearInterval(intervalId); + }; + }, [open, claudeUsage, isStale, fetchUsage]); + + // Derived status color/icon helper + const getStatusInfo = (percentage: number) => { + if (percentage >= 75) 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; + }) => ( +
+
+
+ ); + + const UsageCard = ({ + title, + subtitle, + percentage, + resetText, + isPrimary = false, + stale = false, + }: { + title: string; + subtitle: string; + percentage: number; + resetText?: string; + isPrimary?: boolean; + stale?: boolean; + }) => { + // Check if percentage is valid (not NaN, not undefined, is a finite number) + const isValidPercentage = typeof percentage === "number" && !isNaN(percentage) && isFinite(percentage); + const safePercentage = isValidPercentage ? percentage : 0; + + const status = getStatusInfo(safePercentage); + const StatusIcon = status.icon; + + return ( +
+
+
+

+ {title} +

+

{subtitle}

+
+ {isValidPercentage ? ( +
+ + + {Math.round(safePercentage)}% + +
+ ) : ( + N/A + )} +
+ + {resetText && ( +
+

+ {title === "Session Usage" && } + {resetText} +

+
+ )} +
+ ); + }; + + // Header Button + const maxPercentage = claudeUsage + ? Math.max(claudeUsage.sessionPercentage || 0, claudeUsage.weeklyPercentage || 0) + : 0; + + const getProgressBarColor = (percentage: number) => { + if (percentage >= 80) return 'bg-red-500'; + if (percentage >= 50) return 'bg-yellow-500'; + return "bg-green-500"; + }; + + const trigger = ( + + ); + + return ( + + {trigger} + + {/* Header */} +
+
+ Claude Usage +
+ {error && ( + + )} +
+ + {/* Content */} +
+ {error ? ( +
+ +
+

{error.message}

+

+ {error.code === ERROR_CODES.API_BRIDGE_UNAVAILABLE ? ( + 'Ensure the Electron bridge is running or restart the app' + ) : ( + <> + Make sure Claude CLI is installed and authenticated via{' '} + claude login + + )} +

+
+
+ ) : !claudeUsage ? ( + // Loading state +
+ +

Loading usage data...

+
+ ) : ( + <> + {/* Primary Card */} + + + {/* Secondary Cards Grid */} +
+ + +
+ + {/* Extra Usage / Cost */} + {claudeUsage.costLimit && claudeUsage.costLimit > 0 && ( + 0 + ? ((claudeUsage.costUsed ?? 0) / claudeUsage.costLimit) * 100 + : 0 + } + stale={isStale} + /> + )} + + )} +
+ + {/* Footer */} +
+ + Claude Status + + + Updates every minute +
+
+
+ ); +} diff --git a/apps/ui/src/components/views/board-view/board-header.tsx b/apps/ui/src/components/views/board-view/board-header.tsx index b70c615d2..f7be59cfc 100644 --- a/apps/ui/src/components/views/board-view/board-header.tsx +++ b/apps/ui/src/components/views/board-view/board-header.tsx @@ -6,6 +6,8 @@ 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"; +import { useAppStore } from "@/store/app-store"; interface BoardHeaderProps { projectName: string; @@ -30,6 +32,13 @@ export function BoardHeader({ addFeatureShortcut, isMounted, }: BoardHeaderProps) { + const apiKeys = useAppStore((state) => state.apiKeys); + + // Hide usage tracking when using API key (only show for Claude Code CLI users) + // Also hide on Windows for now (CLI usage command not supported) + const isWindows = typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win'); + const showUsageTracking = !apiKeys.anthropic && !isWindows; + return (
@@ -37,6 +46,9 @@ export function BoardHeader({

{projectName}

+ {/* Usage Popover - only show for CLI users (not API key users) */} + {isMounted && showUsageTracking && } + {/* Concurrency Slider - only show after mount to prevent hydration issues */} {isMounted && (
+
+ + {showUsageTracking && } +
); case "ai-enhancement": return ; diff --git a/apps/ui/src/components/views/settings-view/api-keys/claude-usage-section.tsx b/apps/ui/src/components/views/settings-view/api-keys/claude-usage-section.tsx new file mode 100644 index 000000000..cfde650d3 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/api-keys/claude-usage-section.tsx @@ -0,0 +1,37 @@ +import { cn } from "@/lib/utils"; + +export function ClaudeUsageSection() { + return ( +
+
+
+
+
+
+

Claude Usage Tracking

+
+

+ Track your Claude Code usage limits. Uses the Claude CLI for data. +

+
+
+ {/* Info about CLI requirement */} +
+

Usage tracking requires Claude Code CLI to be installed and authenticated:

+
    +
  1. Install Claude Code CLI if not already installed
  2. +
  3. Run claude login to authenticate
  4. +
  5. Usage data will be fetched automatically every ~minute
  6. +
+
+
+
+ ); +} diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index cdaaf67cc..bdb097486 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -1,5 +1,6 @@ // Type definitions for Electron IPC API import type { SessionListItem, Message } from "@/types/electron"; +import type { ClaudeUsageResponse } from "@/store/app-store"; import { getJSON, setJSON, removeItem } from "./storage"; export interface FileEntry { @@ -482,6 +483,9 @@ export interface ElectronAPI { sessionId: string ) => Promise<{ success: boolean; error?: string }>; }; + claude?: { + getUsage: () => Promise; + }; } // Note: Window interface is declared in @/types/electron.d.ts @@ -879,6 +883,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", + sonnetWeeklyTokensUsed: 0, + sonnetWeeklyPercentage: 1, + sonnetResetText: "Resets Dec 27", + costUsed: null, + costLimit: null, + costCurrency: null, + lastUpdated: new Date().toISOString(), + userTimezone: "UTC" + }; + }, + } }; }; diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 59c9305db..b713472a3 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -24,7 +24,7 @@ import type { SuggestionType, } from "./electron"; import type { Message, SessionListItem } from "@/types/electron"; -import type { Feature } from "@/store/app-store"; +import type { Feature, ClaudeUsageResponse } from "@/store/app-store"; import type { WorktreeAPI, GitAPI, @@ -1016,6 +1016,11 @@ export class HttpApiClient implements ElectronAPI { ): Promise<{ success: boolean; error?: string }> => this.httpDelete(`/api/sessions/${sessionId}`), }; + + // Claude API + claude = { + getUsage: (): Promise => this.get("/api/claude/usage"), + }; } // Singleton instance diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 370c9ed84..43532486d 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -431,6 +431,75 @@ 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 type ClaudeUsage = { + sessionTokensUsed: number; + sessionLimit: number; + sessionPercentage: number; + sessionResetTime: string; + sessionResetText: string; + + weeklyTokensUsed: number; + weeklyLimit: number; + weeklyPercentage: number; + weeklyResetTime: string; + weeklyResetText: string; + + sonnetWeeklyTokensUsed: number; + sonnetWeeklyPercentage: number; + sonnetResetText: string; + + costUsed: number | null; + costLimit: number | null; + costCurrency: string | null; + + lastUpdated: string; + userTimezone: string; +}; + +// Response type for Claude usage API (can be success or error) +export type ClaudeUsageResponse = + | ClaudeUsage + | { error: string; message?: string }; + +/** + * 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 @@ -665,6 +734,11 @@ export interface AppActions { } | null ) => void; + // Claude Usage Tracking actions + setClaudeRefreshInterval: (interval: number) => void; + setClaudeUsageLastUpdated: (timestamp: number) => void; + setClaudeUsage: (usage: ClaudeUsage | null) => void; + // Reset reset: () => void; } @@ -756,6 +830,9 @@ const initialState: AppState = { defaultRequirePlanApproval: false, defaultAIProfileId: null, pendingPlanApproval: null, + claudeRefreshInterval: 60, + claudeUsage: null, + claudeUsageLastUpdated: null, }; export const useAppStore = create()( @@ -1052,8 +1129,10 @@ export const useAppStore = create()( }, addFeature: (feature) => { - const id = feature.id || `feature-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - const featureWithId = { ...feature, id } as Feature; + const id = + feature.id || + `feature-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const featureWithId = { ...feature, id } as unknown as Feature; set({ features: [...get().features, featureWithId] }); return featureWithId; }, @@ -2096,6 +2175,14 @@ export const useAppStore = create()( // 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), }), @@ -2168,6 +2255,10 @@ export const useAppStore = create()( defaultPlanningMode: state.defaultPlanningMode, defaultRequirePlanApproval: state.defaultRequirePlanApproval, defaultAIProfileId: state.defaultAIProfileId, + // Claude usage tracking + claudeUsage: state.claudeUsage, + claudeUsageLastUpdated: state.claudeUsageLastUpdated, + claudeRefreshInterval: state.claudeRefreshInterval, }), } )