Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/*
Expand Down
2 changes: 2 additions & 0 deletions docs/plugins/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand All @@ -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:**

Expand Down
2 changes: 2 additions & 0 deletions docs/plugins/schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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

Expand Down
9 changes: 6 additions & 3 deletions plugins/claude/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand All @@ -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") {
Expand All @@ -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
}))
}

Expand Down
9 changes: 9 additions & 0 deletions plugins/codex/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -229,6 +233,7 @@
limit: 100,
format: { kind: "percent" },
resetsAt: getResetsAtIso(ctx, nowSec, primaryWindow),
periodDurationMs: PERIOD_SESSION_MS
}))
}
if (headerSecondary !== null) {
Expand All @@ -238,6 +243,7 @@
limit: 100,
format: { kind: "percent" },
resetsAt: getResetsAtIso(ctx, nowSec, secondaryWindow),
periodDurationMs: PERIOD_WEEKLY_MS
}))
}

Expand All @@ -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") {
Expand All @@ -258,6 +265,7 @@
limit: 100,
format: { kind: "percent" },
resetsAt: getResetsAtIso(ctx, nowSec, secondaryWindow),
periodDurationMs: PERIOD_WEEKLY_MS
}))
}
}
Expand All @@ -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
}))
}
}
Expand Down
11 changes: 11 additions & 0 deletions plugins/cursor/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/plugin_engine/host_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
},
Expand Down
24 changes: 24 additions & 0 deletions src-tauri/src/plugin_engine/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ pub enum MetricLine {
format: ProgressFormat,
#[serde(rename = "resetsAt")]
resets_at: Option<String>,
#[serde(rename = "periodDurationMs")]
period_duration_ms: Option<u64>,
color: Option<String>,
},
Badge {
Expand Down Expand Up @@ -359,12 +361,34 @@ fn parse_lines(result: &Object) -> Result<Vec<MetricLine>, String> {
Err(_) => None,
};

// Parse optional periodDurationMs
let period_duration_ms: Option<u64> = 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
}
Comment on lines +369 to +376
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low

plugin_engine/runtime.rs:369 Consider adding an is_finite() check before casting n to u64, consistent with the validation for used and limit above. Without it, Infinity saturates to u64::MAX and silently passes the > 0 check.

Suggested change
} 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 if let Some(n) = val.as_number() {
if !n.is_finite() || n < 0.0 {
log::warn!("periodDurationMs at index {} must be a finite positive number, omitting", idx);
None
} else {
let ms = n as u64;
if ms > 0 {
Some(ms)
} else {
log::warn!("periodDurationMs at index {} must be positive, omitting", idx);
None
}
}

🚀 Want me to fix this? Reply ex: "fix it for me".

} 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,
});
}
Expand Down
59 changes: 57 additions & 2 deletions src/components/provider-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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>
<TooltipTrigger
render={(props) => (
<span
{...props}
className={`inline-block w-2 h-2 rounded-full ${colorClass}`}
aria-label={tooltip}
/>
)}
/>
<TooltipContent side="top" className="text-xs">
{tooltip}
</TooltipContent>
</Tooltip>
)
}

export function ProviderCard({
name,
plan,
Expand Down Expand Up @@ -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 ? "ahead" : (paceResult?.status ?? null)
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated

return (
<div>
<div className="text-sm font-medium mb-1.5">{line.label}</div>
<div className="text-sm font-medium mb-1.5 flex items-center gap-1.5">
{line.label}
{paceStatus && <PaceIndicator status={paceStatus} />}
</div>
<Progress
value={percent}
indicatorColor={line.color}
Expand All @@ -300,7 +353,9 @@ function MetricLineRenderer({
{primaryText}
</span>
{secondaryText && (
<span className="text-xs text-muted-foreground">{secondaryText}</span>
<span className="text-xs text-muted-foreground">
{secondaryText}
</span>
)}
</div>
</div>
Expand Down
4 changes: 2 additions & 2 deletions src/components/ui/tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,13 @@ function TooltipContent({
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 rounded-md px-3 py-1.5 text-xs data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 bg-foreground text-background z-50 w-fit max-w-xs origin-(--transform-origin)",
"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 rounded-md px-3 py-1.5 text-xs data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 bg-popover text-popover-foreground border border-border shadow-md z-50 w-fit max-w-xs origin-(--transform-origin)",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 bg-foreground fill-foreground z-50 data-[side=bottom]:top-1 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
<TooltipPrimitive.Arrow className="size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 bg-popover fill-popover z-50 data-[side=bottom]:top-1 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
Expand Down
86 changes: 86 additions & 0 deletions src/lib/pace-status.ts
Original file line number Diff line number Diff line change
@@ -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"
}
}
1 change: 1 addition & 0 deletions src/lib/plugin-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down