Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 && line.periodDurationMs ? "ahead" : (paceResult?.status ?? null)

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