Feed AGS Claude card from Omarchy agent usage records
Claude-Session: https://claude.ai/code/session_014ADhvRXuiTvrXSbConxUey
This commit is contained in:
+18
-28
@@ -1,32 +1,22 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
PROJECTS_DIR="$HOME/.claude/projects"
|
USAGE_RECORD="${XDG_STATE_HOME:-$HOME/.local/state}/omarchy/agents/usage/claude.json"
|
||||||
TODAY=$(date -u '+%Y-%m-%d')
|
MAX_RECORD_AGE_SECONDS=900
|
||||||
EMPTY='{"available":false,"tokens":0,"messages":0,"cacheHitPercent":0}'
|
EMPTY='{"ready":false}'
|
||||||
|
|
||||||
if [ ! -d "$PROJECTS_DIR" ]; then
|
record_is_fresh() {
|
||||||
echo "$EMPTY"
|
[ -f "$USAGE_RECORD" ] || return 1
|
||||||
exit 0
|
local record_age
|
||||||
fi
|
record_age=$(( $(date +%s) - $(stat -c %Y "$USAGE_RECORD") ))
|
||||||
|
[ "$record_age" -lt "$MAX_RECORD_AGE_SECONDS" ]
|
||||||
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"
|
|
||||||
|
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"
|
||||||
|
fi
|
||||||
|
|||||||
+60
-1
@@ -409,7 +409,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.qs-side-panel {
|
.qs-side-panel {
|
||||||
min-width: 240px;
|
min-width: 280px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.widget-card {
|
.widget-card {
|
||||||
@@ -481,6 +481,65 @@
|
|||||||
background-color: @accent;
|
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 {
|
.control-center {
|
||||||
min-width: 360px;
|
min-width: 360px;
|
||||||
}
|
}
|
||||||
|
|||||||
+308
-30
@@ -1,55 +1,333 @@
|
|||||||
import { Gtk } from "ags/gtk3"
|
import { Gtk } from "ags/gtk3"
|
||||||
import { createPoll } from "ags/time"
|
import { createPoll } from "ags/time"
|
||||||
|
import { For } from "ags"
|
||||||
import { shell } from "../lib/utils"
|
import { shell } from "../lib/utils"
|
||||||
import { CardHeader, StatRow } from "./WidgetCard"
|
import { StatRow } from "./WidgetCard"
|
||||||
|
|
||||||
type ClaudeUsage = {
|
type Limit = {
|
||||||
available: boolean
|
label: string
|
||||||
tokens: number
|
percent: number
|
||||||
messages: number
|
fraction: number
|
||||||
cacheHitPercent: number
|
resetsIn: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const usage = createPoll<ClaudeUsage>(
|
type DayUsage = {
|
||||||
{ available: false, tokens: 0, messages: 0, cacheHitPercent: 0 },
|
key: string
|
||||||
60000,
|
label: string
|
||||||
shell("bash $HOME/.config/ags/lib/claude-usage.sh"),
|
tokens: string
|
||||||
(stdout) => {
|
fraction: number
|
||||||
try {
|
isToday: boolean
|
||||||
return JSON.parse(stdout.trim())
|
detail: string
|
||||||
} catch {
|
|
||||||
return { available: false, tokens: 0, messages: 0, cacheHitPercent: 0 }
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
)
|
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) {
|
function formatTokens(tokens: number) {
|
||||||
|
if (tokens >= 1000000000) return `${(tokens / 1000000000).toFixed(2)}B`
|
||||||
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(2)}M`
|
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(2)}M`
|
||||||
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`
|
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<AgentUsage>(
|
||||||
|
EMPTY_USAGE,
|
||||||
|
60000,
|
||||||
|
shell("bash $HOME/.config/ags/lib/claude-usage.sh"),
|
||||||
|
(stdout) => parseUsage(stdout),
|
||||||
|
)
|
||||||
|
|
||||||
|
function SectionTitle({ title }: { title: string }) {
|
||||||
|
return <label class="widget-section-title" label={title} xalign={0} halign={Gtk.Align.START} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function LimitRow({ limit }: { limit: Limit }) {
|
||||||
|
return (
|
||||||
|
<box class="agent-limit" vertical spacing={4}>
|
||||||
|
<box spacing={8}>
|
||||||
|
<label class="widget-card-label" label={limit.label} xalign={0} halign={Gtk.Align.START} hexpand />
|
||||||
|
<label class="widget-card-value" label={`${limit.percent.toFixed(1)}%`} />
|
||||||
|
</box>
|
||||||
|
<levelbar class="widget-level-bar" value={limit.fraction} />
|
||||||
|
<label class="agent-limit-reset" label={limit.resetsIn} xalign={0} halign={Gtk.Align.START} />
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BarRow({ label, tokens, fraction, detail, emphasized }: {
|
||||||
|
label: string
|
||||||
|
tokens: string
|
||||||
|
fraction: number
|
||||||
|
detail: string
|
||||||
|
emphasized: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<box class="agent-bar-row" spacing={8} tooltipText={detail}>
|
||||||
|
<label
|
||||||
|
class={emphasized ? "agent-bar-label emphasized" : "agent-bar-label"}
|
||||||
|
label={label}
|
||||||
|
xalign={0}
|
||||||
|
halign={Gtk.Align.START}
|
||||||
|
/>
|
||||||
|
<levelbar class="widget-level-bar agent-bar" value={fraction} hexpand />
|
||||||
|
<label
|
||||||
|
class={emphasized ? "agent-bar-value emphasized" : "agent-bar-value"}
|
||||||
|
label={tokens}
|
||||||
|
xalign={1}
|
||||||
|
halign={Gtk.Align.END}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ClaudeUsageCard() {
|
export function ClaudeUsageCard() {
|
||||||
return (
|
return (
|
||||||
<box class="panel widget-card" vertical spacing={10}>
|
<box class="panel widget-card" vertical spacing={10}>
|
||||||
<CardHeader icon={""} title="Claude Code" />
|
<box class="widget-card-header" spacing={10}>
|
||||||
|
<box class="widget-icon-badge">
|
||||||
|
<label label={""} hexpand vexpand halign={Gtk.Align.CENTER} valign={Gtk.Align.CENTER} />
|
||||||
|
</box>
|
||||||
|
<box vertical hexpand>
|
||||||
<label
|
<label
|
||||||
class="widget-card-empty"
|
class="widget-card-title"
|
||||||
label="No usage logs found"
|
label={usage.as((u) => u.name)}
|
||||||
|
xalign={0}
|
||||||
|
halign={Gtk.Align.START}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
class="agent-plan"
|
||||||
|
label={usage.as((u) => u.plan)}
|
||||||
|
visible={usage.as((u) => u.plan.length > 0)}
|
||||||
xalign={0}
|
xalign={0}
|
||||||
halign={Gtk.Align.START}
|
halign={Gtk.Align.START}
|
||||||
visible={usage.as((u) => !u.available)}
|
|
||||||
/>
|
/>
|
||||||
<box vertical spacing={8} visible={usage.as((u) => u.available)}>
|
|
||||||
<StatRow icon={""} label="Tokens today" value={usage.as((u) => formatTokens(u.tokens))} />
|
|
||||||
<StatRow icon={""} label="Messages today" value={usage.as((u) => `${u.messages}`)} />
|
|
||||||
<box vertical spacing={4}>
|
|
||||||
<box spacing={8}>
|
|
||||||
<label class="widget-card-label" label="Cache hit rate" xalign={0} halign={Gtk.Align.START} hexpand />
|
|
||||||
<label class="widget-card-value" label={usage.as((u) => `${Math.round(u.cacheHitPercent)}%`)} />
|
|
||||||
</box>
|
</box>
|
||||||
<levelbar class="widget-level-bar" value={usage.as((u) => u.cacheHitPercent / 100)} />
|
|
||||||
</box>
|
</box>
|
||||||
|
|
||||||
|
<label
|
||||||
|
class="widget-card-empty"
|
||||||
|
label="No agent usage recorded yet"
|
||||||
|
xalign={0}
|
||||||
|
halign={Gtk.Align.START}
|
||||||
|
visible={usage.as((u) => !u.ready)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<box vertical spacing={4} visible={usage.as((u) => u.statusText.length > 0)}>
|
||||||
|
<label class="agent-status" label={usage.as((u) => u.statusText)} xalign={0} halign={Gtk.Align.START} />
|
||||||
|
<label
|
||||||
|
class="agent-help"
|
||||||
|
label={usage.as((u) => u.helpText)}
|
||||||
|
xalign={0}
|
||||||
|
halign={Gtk.Align.START}
|
||||||
|
wrap
|
||||||
|
maxWidthChars={30}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<box vertical spacing={8} visible={usage.as((u) => u.limits.length > 0)}>
|
||||||
|
<SectionTitle title="Limits" />
|
||||||
|
<For each={usage.as((u) => u.limits)} id={(limit: Limit) => limit.label}>
|
||||||
|
{(limit: Limit) => <LimitRow limit={limit} />}
|
||||||
|
</For>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<box vertical spacing={8} visible={usage.as((u) => u.ready)}>
|
||||||
|
<SectionTitle title="Today" />
|
||||||
|
<StatRow icon={""} label="Tokens" value={usage.as((u) => u.todayTokens)} />
|
||||||
|
<StatRow icon={""} label="Prompts" value={usage.as((u) => `${u.todayPrompts}`)} />
|
||||||
|
<StatRow icon={""} label="Sessions" value={usage.as((u) => `${u.todaySessions}`)} />
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<box vertical spacing={6} visible={usage.as((u) => u.days.length > 0)}>
|
||||||
|
<SectionTitle title="Tokens by day" />
|
||||||
|
<For each={usage.as((u) => u.days)} id={(day: DayUsage) => day.key}>
|
||||||
|
{(day: DayUsage) => (
|
||||||
|
<BarRow
|
||||||
|
label={day.label}
|
||||||
|
tokens={day.tokens}
|
||||||
|
fraction={day.fraction}
|
||||||
|
detail={day.detail}
|
||||||
|
emphasized={day.isToday}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<box vertical spacing={6} visible={usage.as((u) => u.models.length > 0)}>
|
||||||
|
<SectionTitle title="Tokens by model" />
|
||||||
|
<For each={usage.as((u) => u.models)} id={(model: ModelUsage) => model.key}>
|
||||||
|
{(model: ModelUsage) => (
|
||||||
|
<BarRow
|
||||||
|
label={model.label}
|
||||||
|
tokens={model.tokens}
|
||||||
|
fraction={model.fraction}
|
||||||
|
detail={model.detail}
|
||||||
|
emphasized={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user