From 81d21ac0c33f5ac93f189e45090dbf71992b5417 Mon Sep 17 00:00:00 2001 From: Robin Ebers Date: Thu, 5 Feb 2026 17:54:35 +0400 Subject: [PATCH 1/5] feat: Add pace tracking indicator for usage metrics Shows whether current usage rate will exhaust quota before reset: - Green dot = ahead of pace (projected <80% of limit) - Yellow dot = on track (80-100% projected) - Red dot = using fast (>100% projected) Changes: - Add periodDurationMs field to progress lines (plugins report period length) - New pace-status.ts utility for calculating projected usage - PaceIndicator component with tooltip next to metric labels - Update tooltip styling to use popover colors with border/shadow Co-authored-by: Cursor --- .gitignore | 1 + plugins/claude/plugin.js | 9 ++- plugins/codex/plugin.js | 14 ++++ plugins/cursor/plugin.js | 9 +++ src-tauri/src/plugin_engine/host_api.rs | 1 + src-tauri/src/plugin_engine/runtime.rs | 24 +++++++ src/components/provider-card.tsx | 56 +++++++++++++++- src/components/ui/tooltip.tsx | 4 +- src/lib/pace-status.ts | 86 +++++++++++++++++++++++++ src/lib/plugin-types.ts | 1 + 10 files changed, 198 insertions(+), 7 deletions(-) create mode 100644 src/lib/pace-status.ts 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/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..38953603 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 })) } } @@ -265,12 +273,18 @@ if (reviewWindow) { const used = reviewWindow.used_percent if (typeof used === "number") { + // Use reset_after_seconds if available, otherwise fall back to session duration + var reviewPeriodMs = PERIOD_SESSION_MS + if (typeof reviewWindow.reset_after_seconds === "number") { + reviewPeriodMs = reviewWindow.reset_after_seconds * 1000 + } lines.push(ctx.line.progress({ label: "Reviews", used: used, limit: 100, format: { kind: "percent" }, resetsAt: getResetsAtIso(ctx, nowSec, reviewWindow), + periodDurationMs: reviewPeriodMs })) } } diff --git a/plugins/cursor/plugin.js b/plugins/cursor/plugin.js index f5fc6595..c3e65fb5 100644 --- a/plugins/cursor/plugin.js +++ b/plugins/cursor/plugin.js @@ -281,12 +281,21 @@ const planUsed = typeof pu.totalSpend === "number" ? pu.totalSpend : pu.limit - (pu.remaining ?? 0) + + // Calculate billing cycle period duration + // Use billingCycleStart/End if available, otherwise default to ~30 days + var billingPeriodMs = 30 * 24 * 60 * 60 * 1000 // 30 days default + if (typeof usage.billingCycleStart === "number" && typeof usage.billingCycleEnd === "number") { + billingPeriodMs = (usage.billingCycleEnd - usage.billingCycleStart) * 1000 + } + 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..cb749bbd 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,24 @@ function MetricLineRenderer({ ? `$${formatNumber(line.limit)} limit` : `${formatCount(line.limit)} ${line.format.suffix}` + // Calculate pace status if we have reset time and period duration + const paceResult = + line.resetsAt && line.periodDurationMs + ? calculatePaceStatus( + line.used, + line.limit, + Date.parse(line.resetsAt), + line.periodDurationMs, + now + ) + : null + return (
-
{line.label}
+
+ {line.label} + {paceResult && } +
{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 } From 90aed4ef8370876b34e095a30ef087d21aed8c78 Mon Sep 17 00:00:00 2001 From: Robin Ebers Date: Thu, 5 Feb 2026 18:12:57 +0400 Subject: [PATCH 2/5] feat: Enhance MetricLine with periodDurationMs for pace tracking - Added periodDurationMs field to MetricLine in API and schema documentation. - Updated progress line implementation to utilize the new periodDurationMs for pace tracking. - Clarified documentation on how periodDurationMs interacts with resetsAt for usage metrics. Co-authored-by: Cursor --- docs/plugins/api.md | 2 ++ docs/plugins/schema.md | 2 ++ plugins/codex/plugin.js | 7 +------ 3 files changed, 5 insertions(+), 6 deletions(-) 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/codex/plugin.js b/plugins/codex/plugin.js index 38953603..ff7f3eb7 100644 --- a/plugins/codex/plugin.js +++ b/plugins/codex/plugin.js @@ -273,18 +273,13 @@ if (reviewWindow) { const used = reviewWindow.used_percent if (typeof used === "number") { - // Use reset_after_seconds if available, otherwise fall back to session duration - var reviewPeriodMs = PERIOD_SESSION_MS - if (typeof reviewWindow.reset_after_seconds === "number") { - reviewPeriodMs = reviewWindow.reset_after_seconds * 1000 - } lines.push(ctx.line.progress({ label: "Reviews", used: used, limit: 100, format: { kind: "percent" }, resetsAt: getResetsAtIso(ctx, nowSec, reviewWindow), - periodDurationMs: reviewPeriodMs + periodDurationMs: PERIOD_SESSION_MS })) } } From f1f000f142848b67bf31d2622bc3349451dc56bf Mon Sep 17 00:00:00 2001 From: Robin Ebers Date: Thu, 5 Feb 2026 18:27:02 +0400 Subject: [PATCH 3/5] fix: Correct period durations for pace tracking - Codex Reviews: use PERIOD_WEEKLY_MS (7 days) per API docs - Cursor billing: parse string timestamps, already in ms (no * 1000) Co-authored-by: Cursor --- plugins/codex/plugin.js | 2 +- plugins/cursor/plugin.js | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/plugins/codex/plugin.js b/plugins/codex/plugin.js index ff7f3eb7..ad983a31 100644 --- a/plugins/codex/plugin.js +++ b/plugins/codex/plugin.js @@ -279,7 +279,7 @@ limit: 100, format: { kind: "percent" }, resetsAt: getResetsAtIso(ctx, nowSec, reviewWindow), - periodDurationMs: PERIOD_SESSION_MS + 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 c3e65fb5..5616af24 100644 --- a/plugins/cursor/plugin.js +++ b/plugins/cursor/plugin.js @@ -283,10 +283,12 @@ : pu.limit - (pu.remaining ?? 0) // Calculate billing cycle period duration - // Use billingCycleStart/End if available, otherwise default to ~30 days + // API returns timestamps as strings in milliseconds var billingPeriodMs = 30 * 24 * 60 * 60 * 1000 // 30 days default - if (typeof usage.billingCycleStart === "number" && typeof usage.billingCycleEnd === "number") { - billingPeriodMs = (usage.billingCycleEnd - usage.billingCycleStart) * 1000 + 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({ From 0a863189af9e177ced469ffee3eb6ef46097fcd3 Mon Sep 17 00:00:00 2001 From: Robin Ebers Date: Thu, 5 Feb 2026 18:28:38 +0400 Subject: [PATCH 4/5] fix: Update pace status logic in MetricLineRenderer - Adjusted pace status calculation to always show "ahead" when usage is zero. - Updated PaceIndicator to reflect the new pace status logic. Co-authored-by: Cursor --- src/components/provider-card.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/provider-card.tsx b/src/components/provider-card.tsx index cb749bbd..711d2ff6 100644 --- a/src/components/provider-card.tsx +++ b/src/components/provider-card.tsx @@ -324,6 +324,7 @@ function MetricLineRenderer({ : `${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( @@ -334,12 +335,14 @@ function MetricLineRenderer({ now ) : null + const paceStatus: PaceStatus | null = + line.used === 0 ? "ahead" : (paceResult?.status ?? null) return (
{line.label} - {paceResult && } + {paceStatus && }
Date: Thu, 5 Feb 2026 18:37:13 +0400 Subject: [PATCH 5/5] fix: Refine pace status logic in MetricLineRenderer - Updated the condition for determining pace status to include a check for periodDurationMs, ensuring "ahead" is only shown when usage is zero and a valid period duration exists. Co-authored-by: Cursor --- src/components/provider-card.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/provider-card.tsx b/src/components/provider-card.tsx index 711d2ff6..2cc61c5b 100644 --- a/src/components/provider-card.tsx +++ b/src/components/provider-card.tsx @@ -336,7 +336,7 @@ function MetricLineRenderer({ ) : null const paceStatus: PaceStatus | null = - line.used === 0 ? "ahead" : (paceResult?.status ?? null) + line.used === 0 && line.periodDurationMs ? "ahead" : (paceResult?.status ?? null) return (