mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 08:33:36 +00:00
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:
@@ -44,6 +44,7 @@ import { AutoModeService } from "./services/auto-mode-service.js";
|
|||||||
import { getTerminalService } from "./services/terminal-service.js";
|
import { getTerminalService } from "./services/terminal-service.js";
|
||||||
import { SettingsService } from "./services/settings-service.js";
|
import { SettingsService } from "./services/settings-service.js";
|
||||||
import { createSpecRegenerationRoutes } from "./routes/app-spec/index.js";
|
import { createSpecRegenerationRoutes } from "./routes/app-spec/index.js";
|
||||||
|
import { createClaudeRoutes } from "./routes/claude/index.js";
|
||||||
|
|
||||||
// Load environment variables
|
// Load environment variables
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
@@ -141,6 +142,7 @@ app.use("/api/workspace", createWorkspaceRoutes());
|
|||||||
app.use("/api/templates", createTemplatesRoutes());
|
app.use("/api/templates", createTemplatesRoutes());
|
||||||
app.use("/api/terminal", createTerminalRoutes());
|
app.use("/api/terminal", createTerminalRoutes());
|
||||||
app.use("/api/settings", createSettingsRoutes(settingsService));
|
app.use("/api/settings", createSettingsRoutes(settingsService));
|
||||||
|
app.use("/api/claude", createClaudeRoutes());
|
||||||
|
|
||||||
// Create HTTP server
|
// Create HTTP server
|
||||||
const server = createServer(app);
|
const server = createServer(app);
|
||||||
|
|||||||
44
apps/server/src/routes/claude/index.ts
Normal file
44
apps/server/src/routes/claude/index.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { Router, Request, Response } from "express";
|
||||||
|
import { ClaudeUsageService } from "../../services/claude-usage-service.js";
|
||||||
|
|
||||||
|
export function createClaudeRoutes(): Router {
|
||||||
|
const router = Router();
|
||||||
|
const service = new ClaudeUsageService();
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
35
apps/server/src/routes/claude/types.ts
Normal file
35
apps/server/src/routes/claude/types.ts
Normal file
@@ -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)"
|
||||||
|
|
||||||
|
opusWeeklyTokensUsed: number;
|
||||||
|
opusWeeklyPercentage: number;
|
||||||
|
opusResetText: 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;
|
||||||
|
};
|
||||||
358
apps/server/src/services/claude-usage-service.ts
Normal file
358
apps/server/src/services/claude-usage-service.ts
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
import { spawn } from "child_process";
|
||||||
|
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`.
|
||||||
|
*
|
||||||
|
* Based on ClaudeBar's implementation approach.
|
||||||
|
*/
|
||||||
|
export class ClaudeUsageService {
|
||||||
|
private claudeBinary = "claude";
|
||||||
|
private timeout = 30000; // 30 second timeout
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Claude CLI is available on the system
|
||||||
|
*/
|
||||||
|
async isAvailable(): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const proc = spawn("which", [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<ClaudeUsage> {
|
||||||
|
const output = await this.executeClaudeUsageCommand();
|
||||||
|
return this.parseUsageOutput(output);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the claude /usage command and return the output
|
||||||
|
* Uses 'expect' to provide a pseudo-TTY since claude requires one
|
||||||
|
*/
|
||||||
|
private executeClaudeUsageCommand(): Promise<string> {
|
||||||
|
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}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 opusData = this.parseSection(lines, "Current week (Sonnet only)", "opus");
|
||||||
|
if (opusData.percentage === 0) {
|
||||||
|
opusData = this.parseSection(lines, "Current week (Sonnet)", "opus");
|
||||||
|
}
|
||||||
|
if (opusData.percentage === 0) {
|
||||||
|
opusData = this.parseSection(lines, "Current week (Opus)", "opus");
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
|
||||||
|
opusWeeklyTokensUsed: 0, // Not available from CLI
|
||||||
|
opusWeeklyPercentage: opusData.percentage,
|
||||||
|
opusResetText: opusData.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 - look for patterns like "65% left" or "35% used"
|
||||||
|
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
|
||||||
|
if (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<string, number> = {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
309
apps/ui/src/components/claude-usage-popover.tsx
Normal file
309
apps/ui/src/components/claude-usage-popover.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { Switch } from "@/components/ui/switch";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Plus, Bot } from "lucide-react";
|
import { Plus, Bot } from "lucide-react";
|
||||||
import { KeyboardShortcut } from "@/hooks/use-keyboard-shortcuts";
|
import { KeyboardShortcut } from "@/hooks/use-keyboard-shortcuts";
|
||||||
|
import { ClaudeUsagePopover } from "@/components/claude-usage-popover";
|
||||||
|
|
||||||
interface BoardHeaderProps {
|
interface BoardHeaderProps {
|
||||||
projectName: string;
|
projectName: string;
|
||||||
@@ -37,6 +38,9 @@ export function BoardHeader({
|
|||||||
<p className="text-sm text-muted-foreground">{projectName}</p>
|
<p className="text-sm text-muted-foreground">{projectName}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
|
{/* Usage Popover */}
|
||||||
|
{isMounted && <ClaudeUsagePopover />}
|
||||||
|
|
||||||
{/* Concurrency Slider - only show after mount to prevent hydration issues */}
|
{/* Concurrency Slider - only show after mount to prevent hydration issues */}
|
||||||
{isMounted && (
|
{isMounted && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { KeyboardMapDialog } from "./settings-view/components/keyboard-map-dialo
|
|||||||
import { DeleteProjectDialog } from "./settings-view/components/delete-project-dialog";
|
import { DeleteProjectDialog } from "./settings-view/components/delete-project-dialog";
|
||||||
import { SettingsNavigation } from "./settings-view/components/settings-navigation";
|
import { SettingsNavigation } from "./settings-view/components/settings-navigation";
|
||||||
import { ApiKeysSection } from "./settings-view/api-keys/api-keys-section";
|
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 { ClaudeCliStatus } from "./settings-view/cli-status/claude-cli-status";
|
||||||
import { AIEnhancementSection } from "./settings-view/ai-enhancement";
|
import { AIEnhancementSection } from "./settings-view/ai-enhancement";
|
||||||
import { AppearanceSection } from "./settings-view/appearance/appearance-section";
|
import { AppearanceSection } from "./settings-view/appearance/appearance-section";
|
||||||
@@ -92,11 +93,14 @@ export function SettingsView() {
|
|||||||
switch (activeView) {
|
switch (activeView) {
|
||||||
case "claude":
|
case "claude":
|
||||||
return (
|
return (
|
||||||
<ClaudeCliStatus
|
<div className="space-y-6">
|
||||||
status={claudeCliStatus}
|
<ClaudeCliStatus
|
||||||
isChecking={isCheckingClaudeCli}
|
status={claudeCliStatus}
|
||||||
onRefresh={handleRefreshClaudeCli}
|
isChecking={isCheckingClaudeCli}
|
||||||
/>
|
onRefresh={handleRefreshClaudeCli}
|
||||||
|
/>
|
||||||
|
<ClaudeUsageSection />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
case "ai-enhancement":
|
case "ai-enhancement":
|
||||||
return <AIEnhancementSection />;
|
return <AIEnhancementSection />;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -482,6 +482,9 @@ export interface ElectronAPI {
|
|||||||
sessionId: string
|
sessionId: string
|
||||||
) => Promise<{ success: boolean; error?: string }>;
|
) => Promise<{ success: boolean; error?: string }>;
|
||||||
};
|
};
|
||||||
|
claude?: {
|
||||||
|
getUsage: () => Promise<any>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: Window interface is declared in @/types/electron.d.ts
|
// Note: Window interface is declared in @/types/electron.d.ts
|
||||||
@@ -879,6 +882,33 @@ const getMockElectronAPI = (): ElectronAPI => {
|
|||||||
|
|
||||||
// Mock Running Agents API
|
// Mock Running Agents API
|
||||||
runningAgents: createMockRunningAgentsAPI(),
|
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"
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1016,6 +1016,11 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
): Promise<{ success: boolean; error?: string }> =>
|
): Promise<{ success: boolean; error?: string }> =>
|
||||||
this.httpDelete(`/api/sessions/${sessionId}`),
|
this.httpDelete(`/api/sessions/${sessionId}`),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Claude API
|
||||||
|
claude = {
|
||||||
|
getUsage: (): Promise<any> => this.get("/api/claude/usage"),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Singleton instance
|
// Singleton instance
|
||||||
|
|||||||
@@ -507,6 +507,67 @@ export interface AppState {
|
|||||||
planContent: string;
|
planContent: string;
|
||||||
planningMode: "lite" | "spec" | "full";
|
planningMode: "lite" | "spec" | "full";
|
||||||
} | null;
|
} | 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
|
// Default background settings for board backgrounds
|
||||||
@@ -756,6 +817,11 @@ export interface AppActions {
|
|||||||
planningMode: "lite" | "spec" | "full";
|
planningMode: "lite" | "spec" | "full";
|
||||||
} | null) => void;
|
} | null) => void;
|
||||||
|
|
||||||
|
// Claude Usage Tracking actions
|
||||||
|
setClaudeRefreshInterval: (interval: number) => void;
|
||||||
|
setClaudeUsageLastUpdated: (timestamp: number) => void;
|
||||||
|
setClaudeUsage: (usage: ClaudeUsage | null) => void;
|
||||||
|
|
||||||
// Reset
|
// Reset
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
@@ -848,6 +914,9 @@ const initialState: AppState = {
|
|||||||
defaultRequirePlanApproval: false,
|
defaultRequirePlanApproval: false,
|
||||||
defaultAIProfileId: null,
|
defaultAIProfileId: null,
|
||||||
pendingPlanApproval: null,
|
pendingPlanApproval: null,
|
||||||
|
claudeRefreshInterval: 60,
|
||||||
|
claudeUsage: null,
|
||||||
|
claudeUsageLastUpdated: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useAppStore = create<AppState & AppActions>()(
|
export const useAppStore = create<AppState & AppActions>()(
|
||||||
@@ -2280,6 +2349,14 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
// Plan Approval actions
|
// Plan Approval actions
|
||||||
setPendingPlanApproval: (approval) => set({ pendingPlanApproval: approval }),
|
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
|
||||||
reset: () => set(initialState),
|
reset: () => set(initialState),
|
||||||
}),
|
}),
|
||||||
@@ -2352,6 +2429,10 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
defaultPlanningMode: state.defaultPlanningMode,
|
defaultPlanningMode: state.defaultPlanningMode,
|
||||||
defaultRequirePlanApproval: state.defaultRequirePlanApproval,
|
defaultRequirePlanApproval: state.defaultRequirePlanApproval,
|
||||||
defaultAIProfileId: state.defaultAIProfileId,
|
defaultAIProfileId: state.defaultAIProfileId,
|
||||||
|
// Claude usage tracking
|
||||||
|
claudeUsage: state.claudeUsage,
|
||||||
|
claudeUsageLastUpdated: state.claudeUsageLastUpdated,
|
||||||
|
claudeRefreshInterval: state.claudeRefreshInterval,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user