Merge remote-tracking branch 'origin/main' into refactor/folder-pattern-compliance

This commit is contained in:
Kacper
2025-12-21 22:06:43 +01:00
12 changed files with 1077 additions and 65 deletions

View File

@@ -1,57 +0,0 @@
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
jobs:
claude-review:
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
prompt: |
REPO: ${{ github.repository }}
PR NUMBER: ${{ github.event.pull_request.number }}
Please review this pull request and provide feedback on:
- Code quality and best practices
- Potential bugs or issues
- Performance considerations
- Security concerns
- Test coverage
Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback.
Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR.
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"'

View File

@@ -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);

View File

@@ -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;
}

View 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)"
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;
};

View File

@@ -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<boolean> {
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<ClaudeUsage> {
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<string> {
if (this.isWindows) {
return this.executeClaudeUsageCommandWindows();
}
return this.executeClaudeUsageCommandMac();
}
/**
* macOS implementation using 'expect' command
*/
private executeClaudeUsageCommandMac(): 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}`));
}
});
});
}
/**
* Windows implementation using node-pty
*/
private executeClaudeUsageCommandWindows(): Promise<string> {
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<string, string>,
});
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<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();
}
}
}

View File

@@ -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<UsageError | 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 = 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;
}) => (
<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;
}) => {
// 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 (
<div
className={cn(
"rounded-xl border bg-card/50 p-4 transition-opacity",
isPrimary ? "border-border/60 shadow-sm" : "border-border/40",
(stale || !isValidPercentage) && "opacity-50"
)}
>
<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>
{isValidPercentage ? (
<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(safePercentage)}%
</span>
</div>
) : (
<span className="text-xs text-muted-foreground">N/A</span>
)}
</div>
<ProgressBar percentage={safePercentage} colorClass={isValidPercentage ? status.bg : "bg-muted-foreground/30"} />
{resetText && (
<div className="mt-2 flex justify-end">
<p className="text-xs text-muted-foreground flex items-center gap-1">
{title === "Session Usage" && <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 >= 80) return 'bg-red-500';
if (percentage >= 50) 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>
{error && (
<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.message}</p>
<p className="text-xs text-muted-foreground">
{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{' '}
<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.sonnetWeeklyPercentage}
resetText={claudeUsage.sonnetResetText}
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>
<span className="text-[10px] text-muted-foreground">Updates every minute</span>
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -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 (
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
<div>
@@ -37,6 +46,9 @@ export function BoardHeader({
<p className="text-sm text-muted-foreground">{projectName}</p>
</div>
<div className="flex gap-2 items-center">
{/* Usage Popover - only show for CLI users (not API key users) */}
{isMounted && showUsageTracking && <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";
@@ -46,8 +47,14 @@ export function SettingsView() {
defaultAIProfileId,
setDefaultAIProfileId,
aiProfiles,
apiKeys,
} = useAppStore();
// 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;
// Convert electron Project to settings-view Project type
const convertProject = (
project: ElectronProject | null
@@ -92,11 +99,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}
/>
{showUsageTracking && <ClaudeUsageSection />}
</div>
);
case "ai-enhancement":
return <AIEnhancementSection />;

View File

@@ -0,0 +1,37 @@
import { cn } from "@/lib/utils";
export function ClaudeUsageSection() {
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 every ~minute</li>
</ol>
</div>
</div>
</div>
);
}

View File

@@ -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<ClaudeUsageResponse>;
};
}
// 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"
};
},
}
};
};

View File

@@ -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<ClaudeUsageResponse> => this.get("/api/claude/usage"),
};
}
// Singleton instance

View File

@@ -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<AppState & AppActions>()(
@@ -1052,8 +1129,10 @@ export const useAppStore = create<AppState & AppActions>()(
},
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<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),
}),
@@ -2168,6 +2255,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,
}),
}
)