diff --git a/.gitignore b/.gitignore index ee3c01f8..dc7cabf5 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ coverage # Agent working files docs/choices.md docs/breadcrumbs.md +plans/* # Build artifacts (contents generated by CI, keep folder for Tauri) src-tauri/resources/bundled_plugins/* diff --git a/docs/plugins/api.md b/docs/plugins/api.md index 55c9b7c9..8f651379 100644 --- a/docs/plugins/api.md +++ b/docs/plugins/api.md @@ -312,6 +312,7 @@ ctx.line.progress({ suffix?: string // Required when kind="count" (e.g. "credits") }, resetsAt?: string | null, // Optional: ISO timestamp for when usage resets + periodDurationMs?: number, // Optional: period length in ms for pace tracking color?: string, // Optional: hex color for progress bar }): MetricLine ``` @@ -321,6 +322,7 @@ Notes: - `used` may exceed `limit` (overages). - For `format.kind: "percent"`, `limit` must be `100`. - Prefer setting `resetsAt` (via `ctx.util.toIso(...)`) instead of putting reset info in other lines. +- `periodDurationMs`: when provided with `resetsAt`, enables the pace tracking indicator (shows if usage rate will exhaust quota before reset). **Example:** diff --git a/docs/plugins/schema.md b/docs/plugins/schema.md index b1f00c4e..18f9f8d8 100644 --- a/docs/plugins/schema.md +++ b/docs/plugins/schema.md @@ -147,6 +147,7 @@ type MetricLine = | { kind: "dollars" } | { kind: "count"; suffix: string }; resetsAt?: string; // ISO timestamp + periodDurationMs?: number; // period length in ms for pace tracking color?: string; } | { type: "badge"; label: string; text: string; color?: string; subtitle?: string } @@ -155,6 +156,7 @@ type MetricLine = - `color`: optional hex string (e.g. `#22c55e`) - `subtitle`: optional text displayed below the line in smaller muted text - `resetsAt`: optional ISO timestamp (UI shows "Resets in ..." automatically) +- `periodDurationMs`: optional period length in milliseconds (enables pace indicator when combined with `resetsAt`) ### Text Line diff --git a/plugins/claude/plugin.js b/plugins/claude/plugin.js index 5a792aee..a719ef01 100644 --- a/plugins/claude/plugin.js +++ b/plugins/claude/plugin.js @@ -353,7 +353,8 @@ used: data.five_hour.utilization, limit: 100, format: { kind: "percent" }, - resetsAt: ctx.util.toIso(data.five_hour.resets_at) + resetsAt: ctx.util.toIso(data.five_hour.resets_at), + periodDurationMs: 5 * 60 * 60 * 1000 // 5 hours })) } if (data.seven_day && typeof data.seven_day.utilization === "number") { @@ -362,7 +363,8 @@ used: data.seven_day.utilization, limit: 100, format: { kind: "percent" }, - resetsAt: ctx.util.toIso(data.seven_day.resets_at) + resetsAt: ctx.util.toIso(data.seven_day.resets_at), + periodDurationMs: 7 * 24 * 60 * 60 * 1000 // 7 days })) } if (data.seven_day_sonnet && typeof data.seven_day_sonnet.utilization === "number") { @@ -371,7 +373,8 @@ used: data.seven_day_sonnet.utilization, limit: 100, format: { kind: "percent" }, - resetsAt: ctx.util.toIso(data.seven_day_sonnet.resets_at) + resetsAt: ctx.util.toIso(data.seven_day_sonnet.resets_at), + periodDurationMs: 7 * 24 * 60 * 60 * 1000 // 7 days })) } diff --git a/plugins/codex/plugin.js b/plugins/codex/plugin.js index e1f716ee..ad983a31 100644 --- a/plugins/codex/plugin.js +++ b/plugins/codex/plugin.js @@ -143,6 +143,10 @@ return null } + // Period durations in milliseconds + var PERIOD_SESSION_MS = 5 * 60 * 60 * 1000 // 5 hours + var PERIOD_WEEKLY_MS = 7 * 24 * 60 * 60 * 1000 // 7 days + function probe(ctx) { const auth = loadAuth(ctx) if (!auth) { @@ -229,6 +233,7 @@ limit: 100, format: { kind: "percent" }, resetsAt: getResetsAtIso(ctx, nowSec, primaryWindow), + periodDurationMs: PERIOD_SESSION_MS })) } if (headerSecondary !== null) { @@ -238,6 +243,7 @@ limit: 100, format: { kind: "percent" }, resetsAt: getResetsAtIso(ctx, nowSec, secondaryWindow), + periodDurationMs: PERIOD_WEEKLY_MS })) } @@ -249,6 +255,7 @@ limit: 100, format: { kind: "percent" }, resetsAt: getResetsAtIso(ctx, nowSec, primaryWindow), + periodDurationMs: PERIOD_SESSION_MS })) } if (data.rate_limit.secondary_window && typeof data.rate_limit.secondary_window.used_percent === "number") { @@ -258,6 +265,7 @@ limit: 100, format: { kind: "percent" }, resetsAt: getResetsAtIso(ctx, nowSec, secondaryWindow), + periodDurationMs: PERIOD_WEEKLY_MS })) } } @@ -271,6 +279,7 @@ limit: 100, format: { kind: "percent" }, resetsAt: getResetsAtIso(ctx, nowSec, reviewWindow), + periodDurationMs: PERIOD_WEEKLY_MS // code_review_rate_limit is a 7-day window })) } } diff --git a/plugins/cursor/plugin.js b/plugins/cursor/plugin.js index f5fc6595..5616af24 100644 --- a/plugins/cursor/plugin.js +++ b/plugins/cursor/plugin.js @@ -281,12 +281,23 @@ const planUsed = typeof pu.totalSpend === "number" ? pu.totalSpend : pu.limit - (pu.remaining ?? 0) + + // Calculate billing cycle period duration + // API returns timestamps as strings in milliseconds + var billingPeriodMs = 30 * 24 * 60 * 60 * 1000 // 30 days default + var cycleStart = Number(usage.billingCycleStart) + var cycleEnd = Number(usage.billingCycleEnd) + if (Number.isFinite(cycleStart) && Number.isFinite(cycleEnd) && cycleEnd > cycleStart) { + billingPeriodMs = cycleEnd - cycleStart // already in ms + } + lines.push(ctx.line.progress({ label: "Plan usage", used: ctx.fmt.dollars(planUsed), limit: ctx.fmt.dollars(pu.limit), format: { kind: "dollars" }, resetsAt: ctx.util.toIso(usage.billingCycleEnd), + periodDurationMs: billingPeriodMs })) if (typeof pu.bonusSpend === "number" && pu.bonusSpend > 0) { diff --git a/src-tauri/src/plugin_engine/host_api.rs b/src-tauri/src/plugin_engine/host_api.rs index a4321e8d..923d30ce 100644 --- a/src-tauri/src/plugin_engine/host_api.rs +++ b/src-tauri/src/plugin_engine/host_api.rs @@ -367,6 +367,7 @@ pub fn inject_utils(ctx: &rquickjs::Ctx<'_>) -> rquickjs::Result<()> { progress: function(opts) { var line = { type: "progress", label: opts.label, used: opts.used, limit: opts.limit, format: opts.format }; if (opts.resetsAt) line.resetsAt = opts.resetsAt; + if (opts.periodDurationMs) line.periodDurationMs = opts.periodDurationMs; if (opts.color) line.color = opts.color; return line; }, diff --git a/src-tauri/src/plugin_engine/runtime.rs b/src-tauri/src/plugin_engine/runtime.rs index 4c2350cf..d682edaf 100644 --- a/src-tauri/src/plugin_engine/runtime.rs +++ b/src-tauri/src/plugin_engine/runtime.rs @@ -28,6 +28,8 @@ pub enum MetricLine { format: ProgressFormat, #[serde(rename = "resetsAt")] resets_at: Option, + #[serde(rename = "periodDurationMs")] + period_duration_ms: Option, color: Option, }, Badge { @@ -359,12 +361,34 @@ fn parse_lines(result: &Object) -> Result, String> { Err(_) => None, }; + // Parse optional periodDurationMs + let period_duration_ms: Option = match line.get::<_, Value>("periodDurationMs") { + Ok(val) => { + if val.is_null() || val.is_undefined() { + None + } else if let Some(n) = val.as_number() { + let ms = n as u64; + if ms > 0 { + Some(ms) + } else { + log::warn!("periodDurationMs at index {} must be positive, omitting", idx); + None + } + } else { + log::warn!("invalid periodDurationMs at index {} (non-number), omitting", idx); + None + } + } + Err(_) => None, + }; + out.push(MetricLine::Progress { label, used, limit, format, resets_at, + period_duration_ms, color, }); } diff --git a/src/components/provider-card.tsx b/src/components/provider-card.tsx index 00d21b78..2cc61c5b 100644 --- a/src/components/provider-card.tsx +++ b/src/components/provider-card.tsx @@ -11,6 +11,7 @@ import { useNowTicker } from "@/hooks/use-now-ticker" import { REFRESH_COOLDOWN_MS, type DisplayMode } from "@/lib/settings" import type { ManifestLine, MetricLine } from "@/lib/plugin-types" import { clamp01 } from "@/lib/utils" +import { calculatePaceStatus, type PaceStatus } from "@/lib/pace-status" interface ProviderCardProps { name: string @@ -60,6 +61,40 @@ function formatResetIn(nowMs: number, resetsAtIso: string): string | null { return "Resets in <1m" } +/** Colored dot indicator showing pace status */ +function PaceIndicator({ status }: { status: PaceStatus }) { + const colorClass = + status === "ahead" + ? "bg-green-500" + : status === "on-track" + ? "bg-yellow-500" + : "bg-red-500" + + const tooltip = + status === "ahead" + ? "Ahead of pace" + : status === "on-track" + ? "On track" + : "Using fast" + + return ( + + ( + + )} + /> + + {tooltip} + + + ) +} + export function ProviderCard({ name, plan, @@ -288,9 +323,27 @@ function MetricLineRenderer({ ? `$${formatNumber(line.limit)} limit` : `${formatCount(line.limit)} ${line.format.suffix}` + // Calculate pace status if we have reset time and period duration + // If used === 0, always show "ahead" (no usage = definitionally ahead of pace) + const paceResult = + line.resetsAt && line.periodDurationMs + ? calculatePaceStatus( + line.used, + line.limit, + Date.parse(line.resetsAt), + line.periodDurationMs, + now + ) + : null + const paceStatus: PaceStatus | null = + line.used === 0 && line.periodDurationMs ? "ahead" : (paceResult?.status ?? null) + return (
-
{line.label}
+
+ {line.label} + {paceStatus && } +
{secondaryText && ( - {secondaryText} + + {secondaryText} + )}
diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx index ee3bfdaa..9f2c18c0 100644 --- a/src/components/ui/tooltip.tsx +++ b/src/components/ui/tooltip.tsx @@ -52,13 +52,13 @@ function TooltipContent({ {children} - + diff --git a/src/lib/pace-status.ts b/src/lib/pace-status.ts new file mode 100644 index 00000000..073b7dfa --- /dev/null +++ b/src/lib/pace-status.ts @@ -0,0 +1,86 @@ +export type PaceStatus = "ahead" | "on-track" | "behind" + +export type PaceResult = { + status: PaceStatus + /** Projected usage at end of period (same unit as used/limit) */ + projectedUsage: number +} + +/** + * Calculate pace status based on current usage rate vs. period duration. + * + * @param used - Current usage amount + * @param limit - Maximum/limit amount + * @param resetsAtMs - Timestamp (ms) when the period resets + * @param periodDurationMs - Total duration of the period (ms) + * @param nowMs - Current timestamp (ms) + * @returns PaceResult or null if calculation not possible + */ +export function calculatePaceStatus( + used: number, + limit: number, + resetsAtMs: number, + periodDurationMs: number, + nowMs: number +): PaceResult | null { + // Validate inputs + if ( + !Number.isFinite(used) || + !Number.isFinite(limit) || + !Number.isFinite(resetsAtMs) || + !Number.isFinite(periodDurationMs) || + !Number.isFinite(nowMs) + ) { + return null + } + + if (limit <= 0 || periodDurationMs <= 0) { + return null + } + + // Calculate period start and elapsed time + const periodStartMs = resetsAtMs - periodDurationMs + const elapsedMs = nowMs - periodStartMs + + // Skip if period hasn't started or we're past the reset + if (elapsedMs <= 0 || nowMs >= resetsAtMs) { + return null + } + + // Skip if less than 5% of period has elapsed (too early to predict accurately) + const elapsedFraction = elapsedMs / periodDurationMs + if (elapsedFraction < 0.05) { + return null + } + + // Calculate projected usage at end of period + // projectedUsage = (used / elapsedTime) * periodDuration + const usageRate = used / elapsedMs + const projectedUsage = usageRate * periodDurationMs + + // Determine status based on projected usage vs limit + let status: PaceStatus + if (projectedUsage <= limit * 0.8) { + status = "ahead" + } else if (projectedUsage <= limit) { + status = "on-track" + } else { + status = "behind" + } + + return { status, projectedUsage } +} + +/** + * Get the CSS color class for a pace status. + */ +export function getPaceStatusColor(status: PaceStatus): string { + switch (status) { + case "ahead": + return "text-green-500" + case "on-track": + return "text-yellow-500" + case "behind": + return "text-red-500" + } +} diff --git a/src/lib/plugin-types.ts b/src/lib/plugin-types.ts index 809e578b..aaa8d1ab 100644 --- a/src/lib/plugin-types.ts +++ b/src/lib/plugin-types.ts @@ -12,6 +12,7 @@ export type MetricLine = limit: number format: ProgressFormat resetsAt?: string + periodDurationMs?: number color?: string } | { type: "badge"; label: string; text: string; color?: string; subtitle?: string }