diff --git a/ags/lib/claude-usage.sh b/ags/lib/claude-usage.sh index 02cf6a6..f12a777 100755 --- a/ags/lib/claude-usage.sh +++ b/ags/lib/claude-usage.sh @@ -1,32 +1,22 @@ #!/bin/bash -PROJECTS_DIR="$HOME/.claude/projects" -TODAY=$(date -u '+%Y-%m-%d') -EMPTY='{"available":false,"tokens":0,"messages":0,"cacheHitPercent":0}' +USAGE_RECORD="${XDG_STATE_HOME:-$HOME/.local/state}/omarchy/agents/usage/claude.json" +MAX_RECORD_AGE_SECONDS=900 +EMPTY='{"ready":false}' -if [ ! -d "$PROJECTS_DIR" ]; then +record_is_fresh() { + [ -f "$USAGE_RECORD" ] || return 1 + local record_age + record_age=$(( $(date +%s) - $(stat -c %Y "$USAGE_RECORD") )) + [ "$record_age" -lt "$MAX_RECORD_AGE_SECONDS" ] +} + +if ! record_is_fresh && command -v omarchy-agent-usage-update &> /dev/null; then + omarchy-agent-usage-update claude >/dev/null 2>&1 +fi + +if [ -f "$USAGE_RECORD" ]; then + cat "$USAGE_RECORD" +else echo "$EMPTY" - exit 0 fi - -FILES=$(find "$PROJECTS_DIR" -name "*.jsonl" -newermt "$TODAY") - -if [ -z "$FILES" ]; then - echo '{"available":true,"tokens":0,"messages":0,"cacheHitPercent":0}' - exit 0 -fi - -jq -s --arg today "$TODAY" ' - [.[] | select(.timestamp != null and (.timestamp | startswith($today)) and .message.usage != null) | .message.usage] as $usages - | ([$usages[] | .input_tokens // 0] | add // 0) as $input - | ([$usages[] | .output_tokens // 0] | add // 0) as $output - | ([$usages[] | .cache_creation_input_tokens // 0] | add // 0) as $cacheCreate - | ([$usages[] | .cache_read_input_tokens // 0] | add // 0) as $cacheRead - | ($input + $cacheCreate + $cacheRead) as $totalInput - | { - available: true, - tokens: ($totalInput + $output), - messages: ($usages | length), - cacheHitPercent: (if $totalInput > 0 then (($cacheRead / $totalInput) * 100) else 0 end) - } -' $FILES 2>/dev/null || echo "$EMPTY" diff --git a/ags/style.css b/ags/style.css index 0ecc271..640e995 100644 --- a/ags/style.css +++ b/ags/style.css @@ -409,7 +409,7 @@ } .qs-side-panel { - min-width: 240px; + min-width: 280px; } .widget-card { @@ -481,6 +481,65 @@ background-color: @accent; } +.widget-section-title { + font-size: 11px; + font-weight: bold; + opacity: 0.55; + margin-top: 2px; +} + +.agent-plan { + font-size: 11px; + color: @accent; +} + +.agent-status { + font-size: 12px; + font-weight: bold; + color: @color3; +} + +.agent-help { + font-size: 11px; + opacity: 0.7; +} + +.agent-limit-reset { + font-size: 10px; + opacity: 0.55; +} + +.agent-bar-row { + padding: 1px 0; +} + +.agent-bar-label { + font-size: 11px; + opacity: 0.8; + min-width: 44px; +} + +.agent-bar-value { + font-size: 11px; + opacity: 0.8; + min-width: 54px; +} + +.agent-bar-label.emphasized, +.agent-bar-value.emphasized { + font-weight: bold; + opacity: 1; + color: @foreground; +} + +.agent-bar { + min-height: 4px; +} + +.agent-bar trough { + min-height: 4px; +} + .control-center { min-width: 360px; } diff --git a/ags/widget/ClaudeUsage.tsx b/ags/widget/ClaudeUsage.tsx index 620f8db..9683ea7 100644 --- a/ags/widget/ClaudeUsage.tsx +++ b/ags/widget/ClaudeUsage.tsx @@ -1,55 +1,333 @@ import { Gtk } from "ags/gtk3" import { createPoll } from "ags/time" +import { For } from "ags" import { shell } from "../lib/utils" -import { CardHeader, StatRow } from "./WidgetCard" +import { StatRow } from "./WidgetCard" -type ClaudeUsage = { - available: boolean - tokens: number - messages: number - cacheHitPercent: number +type Limit = { + label: string + percent: number + fraction: number + resetsIn: string } -const usage = createPoll( - { available: false, tokens: 0, messages: 0, cacheHitPercent: 0 }, - 60000, - shell("bash $HOME/.config/ags/lib/claude-usage.sh"), - (stdout) => { - try { - return JSON.parse(stdout.trim()) - } catch { - return { available: false, tokens: 0, messages: 0, cacheHitPercent: 0 } - } - }, -) +type DayUsage = { + key: string + label: string + tokens: string + fraction: number + isToday: boolean + detail: string +} + +type ModelUsage = { + key: string + label: string + tokens: string + fraction: number + detail: string +} + +type AgentUsage = { + ready: boolean + name: string + plan: string + statusText: string + helpText: string + todayPrompts: number + todaySessions: number + todayTokens: string + limits: Limit[] + days: DayUsage[] + models: ModelUsage[] +} + +const EMPTY_USAGE: AgentUsage = { + ready: false, + name: "Claude Code", + plan: "", + statusText: "", + helpText: "", + todayPrompts: 0, + todaySessions: 0, + todayTokens: "0", + limits: [], + days: [], + models: [], +} + +const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] function formatTokens(tokens: number) { + if (tokens >= 1000000000) return `${(tokens / 1000000000).toFixed(2)}B` if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(2)}M` if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K` - return `${tokens}` + return `${Math.round(tokens)}` +} + +function localDateKey(date: Date) { + const month = `${date.getMonth() + 1}`.padStart(2, "0") + const day = `${date.getDate()}`.padStart(2, "0") + return `${date.getFullYear()}-${month}-${day}` +} + +function formatResetsIn(resetsAt: string) { + const resetTime = Date.parse(resetsAt) + if (!resetTime) return "" + + const minutesLeft = Math.round((resetTime - Date.now()) / 60000) + if (minutesLeft <= 0) return "resetting" + + const days = Math.floor(minutesLeft / 1440) + const hours = Math.floor((minutesLeft % 1440) / 60) + const minutes = minutesLeft % 60 + + if (days > 0) return `resets in ${days}d ${hours}h` + if (hours > 0) return `resets in ${hours}h ${minutes}m` + return `resets in ${minutes}m` +} + +function formatModelName(model: string) { + return model + .replace(/^claude-/, "") + .replace(/-/g, " ") + .replace(/(^| )(\w)/g, (_match, space, letter) => `${space}${letter.toUpperCase()}`) +} + +function parseLimits(record: any): Limit[] { + if (!Array.isArray(record.limits)) return [] + + return record.limits.map((limit: any) => { + const percent = Number(limit.percent) || 0 + return { + label: String(limit.label ?? ""), + percent, + fraction: Math.min(1, Math.max(0, percent / 100)), + resetsIn: formatResetsIn(String(limit.resetsAt ?? "")), + } + }) +} + +function parseDays(record: any): DayUsage[] { + if (!Array.isArray(record.recentDays)) return [] + + const todayKey = localDateKey(new Date()) + const dayTokens = record.recentDays.map((day: any) => Number(day.messageCount) || 0) + const busiestDay = Math.max(...dayTokens, 1) + + return record.recentDays.map((day: any, index: number) => { + const date = String(day.date ?? "") + const tokens = dayTokens[index] + const isToday = date === todayKey + const parsedDate = new Date(`${date}T00:00:00`) + const weekday = WEEKDAYS[parsedDate.getDay()] ?? date + + return { + key: date, + label: isToday ? "Today" : weekday, + tokens: formatTokens(tokens), + fraction: tokens / busiestDay, + isToday, + detail: isToday + ? `${record.todayPrompts ?? 0} prompts across ${record.todaySessions ?? 0} sessions` + : date, + } + }) +} + +function parseModels(record: any): ModelUsage[] { + const usageByModel = record.modelUsage + if (!usageByModel || typeof usageByModel !== "object") return [] + + const models = Object.keys(usageByModel).map((model) => { + const usage = usageByModel[model] ?? {} + const input = Number(usage.inputTokens) || 0 + const output = Number(usage.outputTokens) || 0 + const cacheCreation = Number(usage.cacheCreationInputTokens) || 0 + const cacheRead = Number(usage.cacheReadInputTokens) || 0 + + return { + key: model, + label: formatModelName(model), + total: input + output + cacheCreation + cacheRead, + detail: + `in ${formatTokens(input)} - out ${formatTokens(output)} - ` + + `cache write ${formatTokens(cacheCreation)} - cache read ${formatTokens(cacheRead)}`, + } + }) + + const heaviestModel = Math.max(...models.map((model) => model.total), 1) + + return models + .sort((left, right) => right.total - left.total) + .map((model) => ({ + key: model.key, + label: model.label, + tokens: formatTokens(model.total), + fraction: model.total / heaviestModel, + detail: model.detail, + })) +} + +function parseUsage(stdout: string): AgentUsage { + try { + const record = JSON.parse(stdout.trim()) + if (!record.ready) return EMPTY_USAGE + + return { + ready: true, + name: String(record.name ?? "Claude Code"), + plan: String(record.tierLabel ?? ""), + statusText: String(record.usageStatusText ?? ""), + helpText: String(record.authHelpText ?? ""), + todayPrompts: Number(record.todayPrompts) || 0, + todaySessions: Number(record.todaySessions) || 0, + todayTokens: formatTokens(Number(record.todayTotalTokens) || 0), + limits: parseLimits(record), + days: parseDays(record), + models: parseModels(record), + } + } catch { + return EMPTY_USAGE + } +} + +const usage = createPoll( + EMPTY_USAGE, + 60000, + shell("bash $HOME/.config/ags/lib/claude-usage.sh"), + (stdout) => parseUsage(stdout), +) + +function SectionTitle({ title }: { title: string }) { + return