-
-
-
- {extra.usedCredits != null
- ? `${formatCents(extra.usedCredits)} spent`
- : "Extra usage"}
-
-
-
- {percent}% used
-
-
-
-
+
+
Extra usage
+
+
-
- {/* Monthly limit */}
- {extra.monthlyLimit != null && (
-
-
- {formatCents(extra.monthlyLimit)}
-
-
- Monthly spend limit
-
-
+ {resetText && (
+
Resets {resetText}
)}
);
diff --git a/packages/ui/src/modules/usage/components/extra-usage-card/use-service.ts b/packages/ui/src/modules/usage/components/extra-usage-card/use-service.ts
new file mode 100644
index 0000000..0ed83f8
--- /dev/null
+++ b/packages/ui/src/modules/usage/components/extra-usage-card/use-service.ts
@@ -0,0 +1,163 @@
+"use client";
+
+import type { EChartsOption } from "echarts";
+import { useMemo } from "react";
+import {
+ resolveChartColor,
+ resolveChartColorAlpha,
+} from "@/components/echarts";
+import type { ExtraUsage } from "@/generated/typeshare-types";
+import { getUsageStatus } from "../usage-category-card/libs";
+import type { UsageStatus } from "../usage-category-card/types";
+
+function buildTheme(status: UsageStatus) {
+ switch (status) {
+ case "healthy":
+ return {
+ primary: resolveChartColor("--chart-3"),
+ gradientStart: resolveChartColor("--chart-3"),
+ gradientEnd: resolveChartColor("--chart-1"),
+ };
+ case "warning":
+ return {
+ primary: resolveChartColor("--chart-4"),
+ gradientStart: resolveChartColor("--chart-4"),
+ gradientEnd: resolveChartColor("--chart-5"),
+ };
+ case "critical":
+ return {
+ primary: resolveChartColor("--destructive"),
+ gradientStart: resolveChartColor("--destructive"),
+ gradientEnd: resolveChartColor("--chart-5"),
+ };
+ }
+}
+
+function prefersReducedMotion(): boolean {
+ if (typeof window === "undefined") return false;
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+}
+
+function formatCents(cents: number): string {
+ return `$${(cents / 100).toFixed(2)}`;
+}
+
+export interface UseExtraUsageCardResult {
+ percentRemaining: number;
+ status: UsageStatus;
+ option: EChartsOption;
+ usedDisplay: string | null;
+ limitDisplay: string | null;
+ resetText: string | null;
+}
+
+/**
+ * Extra usage mirrors the bucket card visuals but the center label shows
+ * the dollar spent, and the sublabel shows the monthly limit. Status is
+ * still computed from percent remaining.
+ */
+export function useExtraUsageCard(extra: ExtraUsage): UseExtraUsageCardResult {
+ return useMemo(() => {
+ const utilization = extra.utilization ?? 0;
+ const percentRemaining = Math.round(
+ Math.max(0, Math.min(100, 100 - utilization)),
+ );
+ const status = getUsageStatus(percentRemaining);
+ const theme = buildTheme(status);
+ const mutedColor = resolveChartColor("--muted-foreground");
+ const reducedMotion = prefersReducedMotion();
+
+ const usedDisplay =
+ extra.usedCredits != null ? formatCents(extra.usedCredits) : null;
+ const limitDisplay =
+ extra.monthlyLimit != null ? formatCents(extra.monthlyLimit) : null;
+
+ const centerText = usedDisplay ?? `${percentRemaining}%`;
+ const sublabelText = limitDisplay ? `of ${limitDisplay}` : "REMAINING";
+
+ const bands: [number, string][] = [
+ [0.2, resolveChartColorAlpha("--destructive", 0.28)],
+ [0.5, resolveChartColorAlpha("--chart-4", 0.28)],
+ [1.0, resolveChartColorAlpha("--chart-3", 0.28)],
+ ];
+
+ const option: EChartsOption = {
+ animation: !reducedMotion,
+ series: [
+ {
+ type: "gauge",
+ startAngle: 90,
+ endAngle: -270,
+ radius: "92%",
+ min: 0,
+ max: 100,
+ progress: {
+ show: true,
+ width: 14,
+ roundCap: true,
+ itemStyle: {
+ color: {
+ type: "linear",
+ x: 0,
+ y: 0,
+ x2: 1,
+ y2: 1,
+ colorStops: [
+ { offset: 0, color: theme.gradientStart },
+ { offset: 1, color: theme.gradientEnd },
+ ],
+ },
+ },
+ },
+ axisLine: {
+ lineStyle: { width: 14, color: bands },
+ },
+ pointer: { show: false },
+ axisTick: { show: false },
+ splitLine: { show: false },
+ axisLabel: { show: false },
+ anchor: { show: false },
+ title: { show: false },
+ detail: {
+ valueAnimation: false,
+ offsetCenter: [0, "-8%"],
+ fontSize: 26,
+ fontWeight: 700,
+ color: theme.primary,
+ formatter: () => centerText,
+ },
+ data: [{ value: percentRemaining }],
+ },
+ {
+ type: "gauge",
+ radius: "92%",
+ startAngle: 90,
+ endAngle: -270,
+ axisLine: { show: false },
+ pointer: { show: false },
+ axisTick: { show: false },
+ splitLine: { show: false },
+ axisLabel: { show: false },
+ title: { show: false },
+ detail: {
+ offsetCenter: [0, "28%"],
+ fontSize: 10,
+ fontWeight: 600,
+ color: mutedColor,
+ formatter: () => sublabelText,
+ },
+ data: [{ value: 0 }],
+ },
+ ],
+ };
+
+ return {
+ percentRemaining,
+ status,
+ option,
+ usedDisplay,
+ limitDisplay,
+ resetText: extra.resetsAt ?? null,
+ };
+ }, [extra]);
+}
diff --git a/packages/ui/src/modules/usage/components/index.ts b/packages/ui/src/modules/usage/components/index.ts
index 8d6bda3..3bb8ac2 100644
--- a/packages/ui/src/modules/usage/components/index.ts
+++ b/packages/ui/src/modules/usage/components/index.ts
@@ -1,3 +1,4 @@
+export { ApiBillingNotice } from "./api-billing-notice";
export { ExtraUsageCard } from "./extra-usage-card";
export { LoginPrompt } from "./login-prompt";
export { UsageBucketCard } from "./usage-category-card";
diff --git a/packages/ui/src/modules/usage/components/usage-category-card/index.tsx b/packages/ui/src/modules/usage/components/usage-category-card/index.tsx
index c3a46e8..9dbe240 100644
--- a/packages/ui/src/modules/usage/components/usage-category-card/index.tsx
+++ b/packages/ui/src/modules/usage/components/usage-category-card/index.tsx
@@ -1,99 +1,41 @@
"use client";
-import { cn } from "@/lib/utils";
+import { EChart } from "@/components/echarts";
import type { UsageBucketCardProps } from "./types";
+import { useUsageBucketCard } from "./use-service";
-function getBarColor(percent: number): string {
- if (percent >= 90) return "bg-red-500";
- if (percent >= 70) return "bg-amber-500";
- return "bg-chart-1";
-}
-
-function getTextColor(percent: number): string {
- if (percent >= 90) return "text-red-600 dark:text-red-400";
- if (percent >= 70) return "text-amber-600 dark:text-amber-400";
- return "text-muted-foreground";
-}
-
-function formatRelativeReset(diffMs: number): string {
- if (diffMs <= 0) return "Resetting soon";
-
- const totalMinutes = Math.ceil(diffMs / 60_000);
- const hours = Math.floor(totalMinutes / 60);
- const minutes = totalMinutes % 60;
-
- if (hours >= 24) {
- const days = Math.floor(hours / 24);
- const remainingHours = hours % 24;
- return `in ${days}d ${remainingHours}h`;
- }
-
- if (hours > 0) return `in ${hours}h ${minutes}m`;
- return `in ${minutes}m`;
-}
-
-function formatAbsoluteReset(date: Date): string {
- const now = new Date();
- const isToday = date.toDateString() === now.toDateString();
- const tomorrow = new Date(now);
- tomorrow.setDate(tomorrow.getDate() + 1);
- const isTomorrow = date.toDateString() === tomorrow.toDateString();
-
- const time = date.toLocaleTimeString(undefined, {
- hour: "2-digit",
- minute: "2-digit",
- });
-
- if (isToday) return `Today ${time}`;
- if (isTomorrow) return `Tomorrow ${time}`;
-
- return date.toLocaleDateString(undefined, {
- month: "short",
- day: "numeric",
- hour: "2-digit",
- minute: "2-digit",
- });
+/** Extract the model name from a label like "Sonnet · weekly" → "Sonnet".
+ * Falls back to the full label if no separator is present. */
+function modelNameFromLabel(label: string): string {
+ const [first] = label.split("·");
+ return first.trim() || label;
}
export function UsageBucketCard({ label, bucket }: UsageBucketCardProps) {
- const percent = Math.round(bucket.utilization ?? 0);
- const barColor = getBarColor(percent);
- const textColor = getTextColor(percent);
+ const { isUntouched, option, resetText } = useUsageBucketCard(bucket);
+
+ // Reset slot priority:
+ // 1. Concrete reset text from the backend → "Resets {text}"
+ // 2. Untouched bucket (utilization null, no reset)
+ // → "You haven't used {model} yet"
+ // 3. Otherwise → hide the line
+ let footer: string | null = null;
+ if (resetText) {
+ footer = `Resets ${resetText}`;
+ } else if (isUntouched) {
+ footer = `You haven't used ${modelNameFromLabel(label)} yet`;
+ }
return (
-
-
-
-
{label}
- {bucket.resetsAt &&
- (() => {
- const resetDate = new Date(bucket.resetsAt);
- const diffMs = resetDate.getTime() - Date.now();
- return (
-
- Resets {formatRelativeReset(diffMs)}
- ·
- {formatAbsoluteReset(resetDate)}
-
- );
- })()}
-
-
- {percent}% used
-
-
-
-
-
+
{label}
+
+
+ {footer &&
{footer}
}
);
}
diff --git a/packages/ui/src/modules/usage/components/usage-category-card/libs.ts b/packages/ui/src/modules/usage/components/usage-category-card/libs.ts
new file mode 100644
index 0000000..ec9686b
--- /dev/null
+++ b/packages/ui/src/modules/usage/components/usage-category-card/libs.ts
@@ -0,0 +1,53 @@
+import { resolveChartColor } from "@/components/echarts";
+import type { UsageStatus, UsageStatusTheme } from "./types";
+
+/**
+ * Map a "percent remaining" value (0-100) to a simple three-tier status.
+ * Matches ClaudeBar's thresholds:
+ * - > 50% remaining → healthy
+ * - 20% < remaining ≤ 50% → warning
+ * - ≤ 20% remaining → critical
+ */
+export function getUsageStatus(percentRemaining: number): UsageStatus {
+ if (percentRemaining <= 20) return "critical";
+ if (percentRemaining <= 50) return "warning";
+ return "healthy";
+}
+
+/**
+ * Resolve theme colors for the gauge at render time by looking up the
+ * Tailwind/shadcn CSS variables. Returns a bundle of colors keyed by
+ * status so callers can interpolate them into an ECharts option.
+ */
+export function buildUsageStatusTheme(status: UsageStatus): UsageStatusTheme {
+ switch (status) {
+ case "healthy":
+ return {
+ primary: resolveChartColor("--chart-3"),
+ gradientStart: resolveChartColor("--chart-3"),
+ gradientEnd: resolveChartColor("--chart-1"),
+ };
+ case "warning":
+ return {
+ primary: resolveChartColor("--chart-4"),
+ gradientStart: resolveChartColor("--chart-4"),
+ gradientEnd: resolveChartColor("--chart-5"),
+ };
+ case "critical":
+ return {
+ primary: resolveChartColor("--destructive"),
+ gradientStart: resolveChartColor("--destructive"),
+ gradientEnd: resolveChartColor("--chart-5"),
+ };
+ }
+}
+
+/**
+ * Returns `true` when the user has requested reduced motion at the OS
+ * level. Used to suppress ECharts' spring animation on gauges so the
+ * usage page respects system accessibility settings.
+ */
+export function prefersReducedMotion(): boolean {
+ if (typeof window === "undefined") return false;
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+}
diff --git a/packages/ui/src/modules/usage/components/usage-category-card/types.ts b/packages/ui/src/modules/usage/components/usage-category-card/types.ts
index 4c32b6f..85d5e96 100644
--- a/packages/ui/src/modules/usage/components/usage-category-card/types.ts
+++ b/packages/ui/src/modules/usage/components/usage-category-card/types.ts
@@ -1,6 +1,30 @@
+import type { EChartsOption } from "echarts";
import type { UsageBucket } from "@/generated/typeshare-types";
export interface UsageBucketCardProps {
label: string;
bucket: UsageBucket;
}
+
+export type UsageStatus = "healthy" | "warning" | "critical";
+
+export interface UsageStatusTheme {
+ /** Primary color for the center number and the progress fill start. */
+ primary: string;
+ /** Gauge progress gradient start color. */
+ gradientStart: string;
+ /** Gauge progress gradient end color. */
+ gradientEnd: string;
+}
+
+export interface UseUsageBucketCardResult {
+ /** `true` when the backend reports the bucket as present but with no
+ * usage yet (e.g. a Max account that hasn't touched Sonnet). The
+ * gauge is still rendered at 100% remaining; the parent uses this
+ * flag to swap the reset text for a "You haven't used X yet" hint. */
+ isUntouched: boolean;
+ percentRemaining: number;
+ status: UsageStatus;
+ option: EChartsOption;
+ resetText: string | null;
+}
diff --git a/packages/ui/src/modules/usage/components/usage-category-card/use-service.ts b/packages/ui/src/modules/usage/components/usage-category-card/use-service.ts
new file mode 100644
index 0000000..439edf4
--- /dev/null
+++ b/packages/ui/src/modules/usage/components/usage-category-card/use-service.ts
@@ -0,0 +1,126 @@
+"use client";
+
+import type { EChartsOption } from "echarts";
+import { useMemo } from "react";
+import {
+ resolveChartColor,
+ resolveChartColorAlpha,
+} from "@/components/echarts";
+import type { UsageBucket } from "@/generated/typeshare-types";
+import {
+ buildUsageStatusTheme,
+ getUsageStatus,
+ prefersReducedMotion,
+} from "./libs";
+import type { UseUsageBucketCardResult } from "./types";
+
+export function useUsageBucketCard(
+ bucket: UsageBucket,
+): UseUsageBucketCardResult {
+ return useMemo(() => {
+ // `utilization: null` from the backend means "section is present but
+ // has no usage yet" — we still render the gauge (full ring at 100%
+ // remaining) so the card matches the other buckets visually.
+ const isUntouched = bucket.utilization == null;
+ const utilization = bucket.utilization ?? 0;
+ const percentRemaining = Math.round(
+ Math.max(0, Math.min(100, 100 - utilization)),
+ );
+ const status = getUsageStatus(percentRemaining);
+ const theme = buildUsageStatusTheme(status);
+ const mutedColor = resolveChartColor("--muted-foreground");
+ const reducedMotion = prefersReducedMotion();
+
+ // Background color bands — inverted semantics because we display
+ // REMAINING: the "safe" zone is where a full ring points (high
+ // remaining values). Bands widths add up to 1.
+ const bands: [number, string][] = [
+ [0.2, resolveChartColorAlpha("--destructive", 0.28)],
+ [0.5, resolveChartColorAlpha("--chart-4", 0.28)],
+ [1.0, resolveChartColorAlpha("--chart-3", 0.28)],
+ ];
+
+ const option: EChartsOption = {
+ animation: !reducedMotion,
+ series: [
+ {
+ type: "gauge",
+ startAngle: 90,
+ endAngle: -270,
+ radius: "92%",
+ min: 0,
+ max: 100,
+ progress: {
+ show: true,
+ width: 14,
+ roundCap: true,
+ itemStyle: {
+ color: {
+ type: "linear",
+ x: 0,
+ y: 0,
+ x2: 1,
+ y2: 1,
+ colorStops: [
+ { offset: 0, color: theme.gradientStart },
+ { offset: 1, color: theme.gradientEnd },
+ ],
+ },
+ },
+ },
+ axisLine: {
+ lineStyle: {
+ width: 14,
+ color: bands,
+ },
+ },
+ pointer: { show: false },
+ axisTick: { show: false },
+ splitLine: { show: false },
+ axisLabel: { show: false },
+ anchor: { show: false },
+ title: { show: false },
+ detail: {
+ valueAnimation: !reducedMotion,
+ offsetCenter: [0, "-8%"],
+ fontSize: 32,
+ fontWeight: 700,
+ color: theme.primary,
+ formatter: "{value}%",
+ },
+ data: [{ value: percentRemaining }],
+ },
+ // Second invisible gauge that only contributes a "REMAINING" sublabel
+ // beneath the big number. Cheaper than adding a `graphic` layer.
+ {
+ type: "gauge",
+ radius: "92%",
+ startAngle: 90,
+ endAngle: -270,
+ axisLine: { show: false },
+ pointer: { show: false },
+ axisTick: { show: false },
+ splitLine: { show: false },
+ axisLabel: { show: false },
+ title: { show: false },
+ detail: {
+ offsetCenter: [0, "28%"],
+ fontSize: 10,
+ fontWeight: 600,
+ color: mutedColor,
+ formatter: "REMAINING",
+ },
+ data: [{ value: 0 }],
+ },
+ ],
+ };
+
+ return {
+ isUntouched,
+ percentRemaining,
+ status,
+ option,
+ resetText: bucket.resetsAt ?? null,
+ };
+ }, [bucket]);
+}
diff --git a/packages/ui/src/modules/usage/components/usage-skeleton/index.tsx b/packages/ui/src/modules/usage/components/usage-skeleton/index.tsx
index 08de6ec..dd925bd 100644
--- a/packages/ui/src/modules/usage/components/usage-skeleton/index.tsx
+++ b/packages/ui/src/modules/usage/components/usage-skeleton/index.tsx
@@ -1,49 +1,28 @@
"use client";
-import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
-function CategorySkeleton({ hasSubtext = true }: { hasSubtext?: boolean }) {
+function GaugeCardSkeleton() {
return (
-
-
-
-
- {hasSubtext &&
}
+
);
}
export function UsageSkeleton() {
return (
-
- {/* Plan usage limits */}
-
-
-
-
-
-
-
- {/* Weekly limits */}
-
-
-
-
-
-
-
-
-
- {/* Extra usage */}
-
-
-
-
+
+
+
+
+
);
}
diff --git a/packages/ui/src/modules/usage/index.tsx b/packages/ui/src/modules/usage/index.tsx
index b13a761..e7e9766 100644
--- a/packages/ui/src/modules/usage/index.tsx
+++ b/packages/ui/src/modules/usage/index.tsx
@@ -4,8 +4,8 @@ import { Clock, Crown, RefreshCw } from "lucide-react";
import { CardError } from "@/components/card-error";
import { PageHeader } from "@/components/page-header";
import { Button } from "@/components/ui/button";
-import { Separator } from "@/components/ui/separator";
import {
+ ApiBillingNotice,
ExtraUsageCard,
LoginPrompt,
UsageBucketCard,
@@ -65,11 +65,22 @@ export function Usage() {
-
+
{status === "loading" &&
}
{status === "login" &&
}
+ {status === "api_billing" && (
+ <>
+ {subscriptionType && (
+
+
+
+ )}
+
+ >
+ )}
+
{status === "error" && (
+
{/* Subscription tier banner */}
{subscriptionType && (
)}
- {/* Session limit (5-hour) */}
- {usage.fiveHour && (
-
-
Session limit
+ {/* Fixed 2-column grid on anything from small screens up so the
+ gauges always land in a 2x2 / 2x3 shape regardless of
+ window width. On very narrow windows (< 640px) fall back
+ to a single column so cards don't get squeezed. */}
+
+ {usage.fiveHour && (
-
- )}
-
- {/* Weekly limits */}
- {(usage.sevenDay ||
- usage.sevenDayOpus ||
- usage.sevenDaySonnet) && (
- <>
- {usage.fiveHour &&
}
-
-
Weekly limits
- {usage.sevenDay && (
-
- )}
- {usage.sevenDay &&
- (usage.sevenDayOpus || usage.sevenDaySonnet) && (
-
- )}
- {usage.sevenDayOpus && (
-
- )}
- {usage.sevenDayOpus && usage.sevenDaySonnet && (
-
- )}
- {usage.sevenDaySonnet && (
-
- )}
-
- >
- )}
-
- {/* Extra usage */}
- {usage.extraUsage?.isEnabled && (
- <>
-
-
-
Extra usage
-
-
- >
- )}
+ )}
+ {usage.sevenDay && (
+
+ )}
+ {usage.sevenDayOpus && (
+
+ )}
+ {usage.sevenDaySonnet && (
+
+ )}
+ {usage.extraUsage?.isEnabled && (
+
+ )}
+
{/* Last updated + refresh policy */}
@@ -173,7 +159,7 @@ function SubscriptionBanner({ type }: { type: string }) {
return (
diff --git a/packages/ui/src/modules/usage/types.ts b/packages/ui/src/modules/usage/types.ts
index 0230f68..cf931fb 100644
--- a/packages/ui/src/modules/usage/types.ts
+++ b/packages/ui/src/modules/usage/types.ts
@@ -1,6 +1,14 @@
import type { SubscriptionUsageResult } from "@/generated/typeshare-types";
-export type FetchStatus = "idle" | "loading" | "success" | "error" | "login";
+export type FetchStatus =
+ | "idle"
+ | "loading"
+ | "success"
+ | "error"
+ | "login"
+ /** Pay-per-use API billing account. No subscription quotas to show;
+ * the page renders an informational empty state instead of gauges. */
+ | "api_billing";
export interface UseServiceReturn {
status: FetchStatus;
diff --git a/packages/ui/src/modules/usage/use-service.ts b/packages/ui/src/modules/usage/use-service.ts
index 6a978d8..ad7c892 100644
--- a/packages/ui/src/modules/usage/use-service.ts
+++ b/packages/ui/src/modules/usage/use-service.ts
@@ -36,6 +36,9 @@ export function useService(): UseServiceReturn {
status = "error";
} else if (data?.usage) {
status = "success";
+ } else if (data && data.subscriptionType === "API" && !data.usage) {
+ // Pay-per-use API billing account — no quotas to show.
+ status = "api_billing";
}
return {
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index d77d729..576ed98 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -49,6 +49,9 @@ notify = { version = "8", default-features = false, features = ["macos_fsevent"]
serde_yaml = "0.9"
which = "8"
tauri-plugin-opener = "2"
+portable-pty = "0.9"
+vt100 = "0.16"
+regex = "1"
[target.'cfg(target_os = "macos")'.dependencies]
security-framework = "3"
diff --git a/src-tauri/src/services/claude_cli_probe.rs b/src-tauri/src/services/claude_cli_probe.rs
new file mode 100644
index 0000000..e3ba6db
--- /dev/null
+++ b/src-tauri/src/services/claude_cli_probe.rs
@@ -0,0 +1,781 @@
+//! Fallback probe that shells out to `claude /usage` when the OAuth API is
+//! unreachable or blocked. Ported from ClaudeBar's `ClaudeUsageProbe.swift`.
+
+use std::path::PathBuf;
+use std::time::Duration;
+
+use anyhow::{bail, Context, Result};
+use regex::Regex;
+
+use super::interactive_runner::{run_interactive, RunOptions};
+use super::terminal_renderer::TerminalRenderer;
+use crate::types::{ExtraUsage, SubscriptionUsageResponse, UsageBucket};
+
+/// Environment variable that must be stripped before invoking `claude` —
+/// setup-tokens only have `user:inference` scope, which can't hit `/usage`.
+const ENV_EXCLUSION: &str = "CLAUDE_CODE_OAUTH_TOKEN";
+
+/// Result of a CLI probe run, combining parsed usage data with any
+/// metadata we could read from the CLI output itself (e.g. the account
+/// tier shown in the header row). Keeping these in one struct lets the
+/// caller avoid touching the Keychain just for a badge string.
+pub struct CliProbeResult {
+ /// Parsed usage buckets. `None` for account types that don't have
+ /// subscription quotas (e.g. pay-per-use API billing accounts).
+ pub usage: Option
,
+ /// Normalized subscription badge: "MAX", "PRO", "API", or a
+ /// pass-through of whatever the header said. `None` means we didn't
+ /// recognize the header line.
+ pub subscription_badge: Option,
+}
+
+pub struct ClaudeCliProbe;
+
+impl ClaudeCliProbe {
+ /// Run `claude /usage` inside a PTY, render the ANSI output, and parse
+ /// the usage buckets. Blocking I/O is offloaded via `spawn_blocking` so
+ /// the async caller isn't held up by the PTY read loop.
+ pub async fn fetch_usage() -> Result {
+ let claude_path = which::which("claude")
+ .context("Claude CLI binary not found in PATH")?;
+ log::debug!("ClaudeCliProbe: using binary at {}", claude_path.display());
+
+ let working_dir = probe_working_directory()?;
+
+ // Best-effort: make sure our probe directory is pre-trusted so the
+ // CLI doesn't stall waiting for the interactive trust dialog.
+ let _ = write_claude_trust(&working_dir);
+
+ let raw = tokio::task::spawn_blocking(move || {
+ run_interactive(
+ &claude_path,
+ RunOptions {
+ args: vec![
+ "/usage".to_string(),
+ "--allowed-tools".to_string(),
+ String::new(),
+ ],
+ input: None,
+ timeout: Duration::from_secs(20),
+ idle_timeout: Duration::from_secs(3),
+ working_directory: Some(working_dir),
+ env_exclusions: vec![ENV_EXCLUSION.to_string()],
+ auto_responses: vec![
+ ("Esc to cancel".to_string(), "\r".to_string()),
+ ("Ready to code here?".to_string(), "\r".to_string()),
+ ("Press Enter to continue".to_string(), "\r".to_string()),
+ ("ctrl+t to disable".to_string(), "\r".to_string()),
+ ("Yes, I trust this folder".to_string(), "\r".to_string()),
+ ],
+ },
+ )
+ })
+ .await
+ .context("Interactive runner panicked")??;
+
+ let rendered = TerminalRenderer::new().render(&raw);
+ log::debug!(
+ "ClaudeCliProbe: rendered {} bytes of /usage output ({} chars)",
+ raw.len(),
+ rendered.len()
+ );
+
+ let subscription_badge = detect_subscription_badge(&rendered);
+
+ // API billing accounts don't have subscription quotas — the CLI
+ // prints a "Sonnet 4.5 · API Usage Billing" header and either no
+ // bucket sections at all or a "/usage is only available for
+ // subscription plans" message. Rather than bailing (which would
+ // fall through to the OAuth API path and ultimately surface a
+ // misleading "Claude Code login required"), return a successful
+ // result with `usage = None` so the frontend can render a
+ // dedicated API-billing empty state.
+ if subscription_badge.as_deref() == Some("API") {
+ return Ok(CliProbeResult {
+ usage: None,
+ subscription_badge,
+ });
+ }
+
+ let usage = parse_usage_output(&rendered)?;
+
+ Ok(CliProbeResult {
+ usage: Some(usage),
+ subscription_badge,
+ })
+ }
+}
+
+/// Parse the cleaned text of `claude /usage` into a `SubscriptionUsageResponse`.
+/// Looks for the four known section labels and the shared "used/left" percentage
+/// pattern. We deliberately ignore reset-time parsing here — the raw text goes
+/// into the UI via `UsageBucket::resets_at` and is formatted client-side when
+/// available.
+fn parse_usage_output(text: &str) -> Result {
+ let lines: Vec<&str> = text.lines().collect();
+ if lines.is_empty() {
+ bail!("Empty CLI output");
+ }
+
+ // Each bucket extracts its own reset — we don't fabricate a shared
+ // weekly reset for sub-buckets (Opus / Sonnet). If the CLI doesn't
+ // print a reset line inside the sub-bucket's window, that bucket's
+ // `resets_at` is `None` and the UI hides it.
+ let five_hour = build_bucket(&lines, &["current session"]);
+ let seven_day = build_bucket(&lines, &["current week (all models)"]);
+ let seven_day_opus = build_bucket(&lines, &["current week (opus)"]);
+ let seven_day_sonnet = build_bucket(
+ &lines,
+ &["current week (sonnet only)", "current week (sonnet)"],
+ );
+
+ // We need at least one bucket — otherwise the output wasn't the usage
+ // screen we expected (e.g. API billing account or an unknown format).
+ if five_hour.is_none()
+ && seven_day.is_none()
+ && seven_day_opus.is_none()
+ && seven_day_sonnet.is_none()
+ {
+ bail!(
+ "Could not find any usage sections in CLI output (got {} chars)",
+ text.len()
+ );
+ }
+
+ Ok(SubscriptionUsageResponse {
+ five_hour,
+ seven_day,
+ seven_day_opus,
+ seven_day_sonnet,
+ extra_usage: extract_extra_usage(text, &lines),
+ })
+}
+
+/// Parse the "Extra usage" section from a Pro/Max account's `/usage` output.
+/// Returns `None` when the section is absent or explicitly disabled.
+///
+/// Expected layout:
+/// ```text
+/// Extra usage
+/// █████░░░░░░░░░░░░░░░ 27% used
+/// $5.41 / $20.00 spent · Resets Jan 1, 2026
+/// ```
+///
+/// The frontend displays `used_credits` / `monthly_limit` as dollars after
+/// dividing by 100, so we store the values in **cents** here to match the
+/// OAuth API response shape.
+fn extract_extra_usage(full_text: &str, lines: &[&str]) -> Option {
+ let lower_full = full_text.to_lowercase();
+ if !lower_full.contains("extra usage") {
+ return None;
+ }
+ // Honor explicit opt-out so we don't render an empty card.
+ if lower_full.contains("extra usage not enabled") {
+ return None;
+ }
+
+ let start_idx = lines
+ .iter()
+ .position(|line| line.to_lowercase().contains("extra usage"))?;
+
+ // The percentage bar isn't guaranteed to be present on every account,
+ // but when it is, it sits within the section window like any other
+ // bucket — reuse the shared extractor so the 12-line window is consistent.
+ let utilization: Option = match extract_percent(lines, "extra usage") {
+ SectionPercent::Used(v) => Some(v),
+ SectionPercent::Untouched | SectionPercent::NotFound => None,
+ };
+
+ // Scan the next ~10 lines for "$X / $Y spent" (the section always shows
+ // the cost line on its own row right after the bar).
+ let cost_re =
+ Regex::new(r"(?i)\$?([\d,]+\.?\d*)\s*/\s*\$?([\d,]+\.?\d*)\s*spent").ok()?;
+
+ // Scan the same 10-line window for a reset text line so we can surface
+ // the Extra usage reset date if the CLI prints one (e.g. "Resets Jan 1, 2026").
+ let resets_at = extract_reset(lines, "extra usage");
+
+ let end = (start_idx + 10).min(lines.len());
+ for line in &lines[start_idx..end] {
+ if let Some(caps) = cost_re.captures(line) {
+ let spent_dollars = caps
+ .get(1)?
+ .as_str()
+ .replace(',', "")
+ .parse::()
+ .ok()?;
+ let budget_dollars = caps
+ .get(2)?
+ .as_str()
+ .replace(',', "")
+ .parse::()
+ .ok()?;
+ // Convert to cents so the frontend's /100 formatter produces the
+ // right display string.
+ return Some(ExtraUsage {
+ is_enabled: true,
+ utilization,
+ used_credits: Some(spent_dollars * 100.0),
+ monthly_limit: Some(budget_dollars * 100.0),
+ resets_at: resets_at.clone(),
+ });
+ }
+ }
+
+ // Section header found but no parseable cost line — still report the
+ // section as enabled so the UI can show the bar without a dollar total.
+ utilization.map(|u| ExtraUsage {
+ is_enabled: true,
+ utilization: Some(u),
+ used_credits: None,
+ monthly_limit: None,
+ resets_at,
+ })
+}
+
+/// Detect the subscription tier from the `claude /usage` header row.
+/// Example headers: `"Opus 4.5 · Claude Max"`, `"Sonnet 4.5 · Claude Pro"`,
+/// `"Sonnet 4.5 · API Usage Billing"`. We only scan the first ~6 rendered
+/// lines since the tier always appears at the top.
+fn detect_subscription_badge(text: &str) -> Option {
+ let head: String = text.lines().take(6).collect::>().join("\n");
+ let lower = head.to_lowercase();
+
+ if lower.contains("· claude max") || lower.contains("·claude max") {
+ Some("MAX".to_string())
+ } else if lower.contains("· claude pro") || lower.contains("·claude pro") {
+ Some("PRO".to_string())
+ } else if lower.contains("api usage billing") {
+ Some("API".to_string())
+ } else {
+ None
+ }
+}
+
+fn build_bucket(lines: &[&str], label_candidates: &[&str]) -> Option {
+ for label in label_candidates {
+ match extract_percent(lines, label) {
+ SectionPercent::NotFound => continue,
+ SectionPercent::Untouched => {
+ // Bucket exists in the CLI output but has no usage yet.
+ // `utilization: None` tells the frontend to render an
+ // empty state ("You haven't used X yet") instead of a
+ // gauge at 100% remaining. Reset is also left None
+ // unless the CLI prints one inside this section's window.
+ return Some(UsageBucket {
+ utilization: None,
+ resets_at: extract_reset(lines, label),
+ });
+ }
+ SectionPercent::Used(pct) => {
+ return Some(UsageBucket {
+ utilization: Some(pct),
+ resets_at: extract_reset(lines, label),
+ });
+ }
+ }
+ }
+ None
+}
+
+/// Tri-state result for reading a section's percent.
+#[derive(Debug, PartialEq)]
+enum SectionPercent {
+ /// The label wasn't in the output at all — bucket does not exist.
+ NotFound,
+ /// The label IS in the output but the bucket has no usage yet
+ /// (CLI prints `0% used` with no progress-bar character, or the
+ /// section window has no percent line at all).
+ Untouched,
+ /// The bucket reports a concrete usage percentage.
+ Used(f64),
+}
+
+/// Find the line containing `label_substring` (case-insensitive) and scan
+/// the next 12 lines for a percentage token. Mirrors ClaudeBar's
+/// "first match wins" behaviour within a small window: once we find a
+/// line that looks like a `X% used/left` statement we commit to that
+/// verdict, preventing subsequent sections from bleeding in.
+fn extract_percent(lines: &[&str], label_substring: &str) -> SectionPercent {
+ let label = label_substring.to_lowercase();
+
+ for (idx, line) in lines.iter().enumerate() {
+ if !line.to_lowercase().contains(&label) {
+ continue;
+ }
+ let end = (idx + 12).min(lines.len());
+ for candidate in &lines[idx + 1..end] {
+ if !line_has_percent_token(candidate) {
+ continue;
+ }
+ // Commit to the first line with a percent token. A "0% used"
+ // line with no filled block character is the CLI's signal
+ // that the bucket exists but hasn't been touched yet — we
+ // report it as Untouched so the UI can render an empty
+ // state. Any other value (including genuine 0% with a bar)
+ // is a real usage value.
+ match percent_from_line(candidate) {
+ Some(v) if v == 0.0 && !has_filled_block_char(candidate) => {
+ return SectionPercent::Untouched;
+ }
+ Some(v) => return SectionPercent::Used(v),
+ // Malformed percent line — fall through and treat the
+ // section as present but empty.
+ None => return SectionPercent::Untouched,
+ }
+ }
+ // Label present, window scanned, no percent token anywhere.
+ return SectionPercent::Untouched;
+ }
+ SectionPercent::NotFound
+}
+
+/// Cheap pre-check: does `line` look like it has `X% used` or `X% left`?
+fn line_has_percent_token(line: &str) -> bool {
+ let lower = line.to_lowercase();
+ (lower.contains("% used") || lower.contains("% left"))
+ && line.chars().any(|c| c.is_ascii_digit())
+}
+
+fn percent_from_line(line: &str) -> Option {
+ // Regex matches "65% left", "25% used", etc. with flexible whitespace.
+ let re = Regex::new(r"(?i)([0-9]{1,3})\s*%\s*(used|left)").ok()?;
+ let caps = re.captures(line)?;
+ let raw = caps.get(1)?.as_str().parse::().ok()?;
+ let kind = caps.get(2)?.as_str().to_lowercase();
+ // API returns `utilization` as percent USED, so we normalize back to
+ // "used" here for consistency with the HTTP path.
+ let used = if kind.contains("left") {
+ 100.0 - raw
+ } else {
+ raw
+ };
+ Some(used.clamp(0.0, 100.0))
+}
+
+/// Returns true if `line` contains any Unicode "filled block" character
+/// in the range `U+2588..=U+258F` (FULL BLOCK through LEFT ONE EIGHTH
+/// BLOCK). These are the glyphs the Claude CLI uses to draw progress
+/// bars. An untouched bucket renders as whitespace + `0% used` with NO
+/// block characters at all — that absence is the "not used yet" signal.
+fn has_filled_block_char(line: &str) -> bool {
+ line.chars().any(|c| ('\u{2588}'..='\u{258F}').contains(&c))
+}
+
+/// Extract the reset text near a section label. The returned string is
+/// display-ready (e.g. `"in 18m"`, `"4:59pm (America/New_York)"`) with the
+/// leading `"Resets "` word removed. The frontend renders it verbatim
+/// under a `"Resets "` label, matching the API path's formatted output.
+///
+/// The forward scan stops at section boundaries (lines that begin a new
+/// known section like `"Extra usage"`) so we never bleed an unrelated
+/// section's reset text into the current bucket.
+fn extract_reset(lines: &[&str], label_substring: &str) -> Option {
+ let label = label_substring.to_lowercase();
+ for (idx, start_line) in lines.iter().enumerate() {
+ if !start_line.to_lowercase().contains(&label) {
+ continue;
+ }
+ let end = (idx + 14).min(lines.len());
+ // Skip the label line itself (index 0 in the slice) — it may contain
+ // the word "reset" as part of its own text on future CLI versions.
+ for candidate in &lines[idx + 1..end] {
+ let lower = candidate.to_lowercase();
+ // Stop if we cross into the Extra usage section. Its cost line
+ // ends in "Resets " which would otherwise be mis-attributed
+ // to preceding weekly buckets when the CLI omits a weekly reset.
+ if lower.contains("extra usage") && !label.contains("extra") {
+ return None;
+ }
+ if lower.contains("reset") {
+ return Some(strip_resets_prefix(candidate));
+ }
+ }
+ return None;
+ }
+ None
+}
+
+/// Strip a leading case-insensitive "Resets" word (and any following
+/// whitespace) from a line. If the line doesn't start with "Resets",
+/// returns the trimmed line as-is — some CLI layouts put the reset text
+/// on a line that begins with cost info (e.g. `"$5.41 / $20.00 spent · Resets Jan 1, 2026"`),
+/// in which case we take everything after the LAST "Resets" token.
+fn strip_resets_prefix(line: &str) -> String {
+ let trimmed = line.trim();
+ // Locate the last case-insensitive "resets" occurrence; this handles
+ // both leading and mid-line cases robustly.
+ let lower = trimmed.to_lowercase();
+ if let Some(pos) = lower.rfind("resets") {
+ let after = &trimmed[pos + "resets".len()..];
+ after.trim_start().to_string()
+ } else {
+ trimmed.to_string()
+ }
+}
+
+/// Create (or reuse) a stable working directory for the probe so every run
+/// happens inside the same folder — avoids the CLI re-prompting for trust.
+fn probe_working_directory() -> Result {
+ let home = dirs::home_dir().context("Could not locate home directory")?;
+ #[cfg(target_os = "macos")]
+ let base = home.join("Library/Application Support/Lumo/Probe");
+ #[cfg(not(target_os = "macos"))]
+ let base = home.join(".lumo/probe");
+
+ std::fs::create_dir_all(&base)
+ .with_context(|| format!("Failed to create probe directory {}", base.display()))?;
+ Ok(base)
+}
+
+/// Pre-mark the probe directory as trusted in `~/.claude.json` so the CLI
+/// doesn't stall on the workspace trust dialog. Safe no-op if the file
+/// doesn't exist or the entry is already present.
+fn write_claude_trust(working_dir: &std::path::Path) -> bool {
+ let home = match dirs::home_dir() {
+ Some(h) => h,
+ None => return false,
+ };
+ let path = home.join(".claude.json");
+ if !path.exists() {
+ return false;
+ }
+
+ let Ok(data) = std::fs::read_to_string(&path) else {
+ return false;
+ };
+ let Ok(mut json) = serde_json::from_str::(&data) else {
+ return false;
+ };
+
+ // Walk into `.projects[]` and set `hasTrustDialogAccepted`.
+ let projects = json
+ .as_object_mut()
+ .and_then(|obj| obj.entry("projects").or_insert_with(|| serde_json::json!({})).as_object_mut());
+ let Some(projects) = projects else {
+ return false;
+ };
+
+ let key = working_dir.to_string_lossy().into_owned();
+ let entry = projects
+ .entry(key)
+ .or_insert_with(|| serde_json::json!({}))
+ .as_object_mut();
+ let Some(entry) = entry else {
+ return false;
+ };
+
+ if entry.get("hasTrustDialogAccepted") == Some(&serde_json::Value::Bool(true)) {
+ return false;
+ }
+
+ entry.insert(
+ "hasTrustDialogAccepted".to_string(),
+ serde_json::Value::Bool(true),
+ );
+
+ let Ok(serialized) = serde_json::to_string_pretty(&json) else {
+ return false;
+ };
+ std::fs::write(&path, serialized).is_ok()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn percent_from_line_handles_used_and_left() {
+ assert_eq!(percent_from_line("18% used"), Some(18.0));
+ assert_eq!(percent_from_line("27% left"), Some(73.0));
+ assert_eq!(percent_from_line("no percent here"), None);
+ }
+
+ #[test]
+ fn extract_percent_walks_label_window() {
+ let lines = vec![
+ "Opus 4.5 · Claude Max",
+ "",
+ "Current session",
+ "█████████████░░░░░░░ 65% left",
+ "Resets 4:59pm",
+ ];
+ assert_eq!(
+ extract_percent(&lines, "current session"),
+ SectionPercent::Used(35.0)
+ );
+ }
+
+ #[test]
+ fn detect_subscription_badge_matches_header() {
+ assert_eq!(
+ detect_subscription_badge("Opus 4.5 · Claude Max · Organization\n"),
+ Some("MAX".to_string())
+ );
+ assert_eq!(
+ detect_subscription_badge("Sonnet 4.5 · Claude Pro\n"),
+ Some("PRO".to_string())
+ );
+ assert_eq!(
+ detect_subscription_badge("Sonnet 4.5 · API Usage Billing\n"),
+ Some("API".to_string())
+ );
+ assert_eq!(detect_subscription_badge("Something else\n"), None);
+ }
+
+ #[test]
+ fn extra_usage_with_cost_line() {
+ let sample = "\
+Opus 4.5 · Claude Pro
+
+Current session
+█████████████░░░░░░░ 18% used
+Resets 4:59pm
+
+Extra usage
+█████░░░░░░░░░░░░░░░ 27% used
+$5.41 / $20.00 spent · Resets Jan 1, 2026
+";
+ let parsed = parse_usage_output(sample).expect("should parse");
+ let extra = parsed.extra_usage.expect("extra usage should be present");
+ assert!(extra.is_enabled);
+ assert_eq!(extra.utilization, Some(27.0));
+ assert_eq!(extra.used_credits, Some(541.0));
+ assert_eq!(extra.monthly_limit, Some(2000.0));
+ // The reset line appears on the same row as the cost, so `strip_resets_prefix`
+ // should take the substring after "Resets" and return just the date.
+ assert_eq!(extra.resets_at.as_deref(), Some("Jan 1, 2026"));
+ }
+
+ #[test]
+ fn strip_resets_prefix_handles_leading_and_inline() {
+ assert_eq!(strip_resets_prefix("Resets in 18m"), "in 18m");
+ assert_eq!(
+ strip_resets_prefix("Resets 4:59pm (America/New_York)"),
+ "4:59pm (America/New_York)"
+ );
+ // Inline usage: "cost line · Resets DATE"
+ assert_eq!(
+ strip_resets_prefix("$5.41 / $20.00 spent · Resets Jan 1, 2026"),
+ "Jan 1, 2026"
+ );
+ // No "Resets" prefix at all: return trimmed as-is
+ assert_eq!(strip_resets_prefix(" some other text "), "some other text");
+ }
+
+ #[test]
+ fn extract_reset_returns_display_ready_text() {
+ let lines = vec![
+ "Current session",
+ "█████████████░░░░░░░ 18% used",
+ "Resets in 2h 15m",
+ ];
+ assert_eq!(
+ extract_reset(&lines, "current session"),
+ Some("in 2h 15m".to_string())
+ );
+ }
+
+ #[test]
+ fn sonnet_weekly_does_not_steal_extra_usage_reset() {
+ // Regression: the CLI only prints one weekly reset (next to "Current
+ // week (all models)"). Sonnet/Opus have no reset line of their own —
+ // per-bucket extraction must stop at the Extra usage boundary so the
+ // `· Resets May 1` fragment doesn't leak into Sonnet.
+ let sample = "\
+Opus 4.5 · Claude Max
+
+Current week (all models)
+█████░░░░░░░░░░░░░░░ 30% used
+Resets in 6d
+
+Current week (Opus)
+█████░░░░░░░░░░░░░░░ 30% used
+
+Current week (Sonnet only)
+████░░░░░░░░░░░░░░░░ 20% used
+
+Extra usage
+█████░░░░░░░░░░░░░░░ 27% used
+$5.41 / $20.00 spent · Resets May 1
+";
+ let parsed = parse_usage_output(sample).expect("should parse");
+
+ let weekly = parsed.seven_day.expect("weekly bucket");
+ assert_eq!(weekly.resets_at.as_deref(), Some("in 6d"));
+
+ // Opus / Sonnet have their own percentages but no dedicated reset
+ // line — they MUST NOT borrow the Extra usage "May 1" text. Their
+ // resets_at is `None` (the UI hides the reset line).
+ let opus = parsed.seven_day_opus.expect("opus bucket");
+ assert_eq!(opus.utilization, Some(30.0));
+ assert!(
+ opus.resets_at.is_none(),
+ "Opus must not leak Extra usage reset, got: {:?}",
+ opus.resets_at
+ );
+
+ let sonnet = parsed.seven_day_sonnet.expect("sonnet bucket");
+ assert_eq!(sonnet.utilization, Some(20.0));
+ assert!(
+ sonnet.resets_at.is_none(),
+ "Sonnet must not leak Extra usage reset, got: {:?}",
+ sonnet.resets_at
+ );
+
+ // Extra usage still gets its own reset.
+ let extra = parsed.extra_usage.expect("extra usage");
+ assert_eq!(extra.resets_at.as_deref(), Some("May 1"));
+ }
+
+ #[test]
+ fn real_cli_output_produces_expected_buckets() {
+ // Exact rendered output captured from a real Max account where the
+ // user hasn't touched Sonnet or Opus. The CLI still prints a
+ // "Current week (Sonnet only)" section with whitespace + "0% used"
+ // (no bar, no reset line). Opus is omitted entirely. Lumo must:
+ // - show session, weekly (all models), extra usage with their
+ // own utilization AND reset strings
+ // - drop opus (section missing from CLI output)
+ // - show sonnet as an "untouched" bucket: `utilization = None`
+ // (no gauge, frontend renders "You haven't used Sonnet yet")
+ // and `resets_at = None` (no reset line in CLI, don't fabricate)
+ let sample = "\
+ Current session
+ ███████████████ 30% used
+ Resets 4am (Asia/Shanghai)
+
+ Current week (all models)
+ ███████████████▌ 31% used
+ Resets 11am (Asia/Shanghai)
+
+ Current week (Sonnet only)
+ 0% used
+
+ Extra usage
+ ▋ 1% used
+ $0.88 / $70.00 spent · Resets May 1 (Asia/Shanghai)
+";
+ let parsed = parse_usage_output(sample).expect("should parse real output");
+
+ let session = parsed.five_hour.as_ref().expect("session bucket");
+ assert_eq!(session.utilization, Some(30.0));
+ assert_eq!(session.resets_at.as_deref(), Some("4am (Asia/Shanghai)"));
+
+ let weekly = parsed.seven_day.as_ref().expect("weekly bucket");
+ assert_eq!(weekly.utilization, Some(31.0));
+ assert_eq!(weekly.resets_at.as_deref(), Some("11am (Asia/Shanghai)"));
+
+ assert!(
+ parsed.seven_day_opus.is_none(),
+ "Opus must be None (section absent), got: {:?}",
+ parsed.seven_day_opus
+ );
+
+ // Sonnet section IS printed but the user hasn't used it. The bucket
+ // exists (so the UI can show an empty state card) with no
+ // utilization and no reset.
+ let sonnet = parsed
+ .seven_day_sonnet
+ .as_ref()
+ .expect("sonnet bucket should be present even at 0%");
+ assert!(
+ sonnet.utilization.is_none(),
+ "Sonnet utilization must be None (untouched), got: {:?}",
+ sonnet.utilization
+ );
+ assert!(
+ sonnet.resets_at.is_none(),
+ "Sonnet resets_at must be None — no reset line in CLI, got: {:?}",
+ sonnet.resets_at
+ );
+
+ let extra = parsed.extra_usage.as_ref().expect("extra usage");
+ assert_eq!(extra.utilization, Some(1.0));
+ assert_eq!(extra.used_credits, Some(88.0));
+ assert_eq!(extra.monthly_limit, Some(7000.0));
+ assert_eq!(extra.resets_at.as_deref(), Some("May 1 (Asia/Shanghai)"));
+ }
+
+ #[test]
+ fn weekly_reset_none_when_cli_omits_it() {
+ // If the CLI renders weekly sections without any "Resets" line in the
+ // weekly block, we must return None (not leak into Extra usage).
+ let sample = "\
+Opus 4.5 · Claude Max
+
+Current week (all models)
+█████░░░░░░░░░░░░░░░ 30% used
+
+Current week (Sonnet only)
+████░░░░░░░░░░░░░░░░ 20% used
+
+Extra usage
+█████░░░░░░░░░░░░░░░ 27% used
+$5.41 / $20.00 spent · Resets May 1
+";
+ let parsed = parse_usage_output(sample).expect("should parse");
+
+ let weekly = parsed.seven_day.expect("weekly bucket");
+ assert!(
+ weekly.resets_at.is_none(),
+ "weekly should not claim Extra usage reset, got: {:?}",
+ weekly.resets_at
+ );
+
+ let sonnet = parsed.seven_day_sonnet.expect("sonnet bucket");
+ assert!(sonnet.resets_at.is_none());
+ }
+
+ #[test]
+ fn extra_usage_not_enabled_returns_none() {
+ let sample = "\
+Opus 4.5 · Claude Pro
+
+Current session
+█████████████░░░░░░░ 18% used
+Resets 4:59pm
+
+Extra usage not enabled
+";
+ let parsed = parse_usage_output(sample).expect("should parse");
+ assert!(parsed.extra_usage.is_none());
+ }
+
+ #[test]
+ fn extra_usage_with_commas_and_decimals() {
+ // Large budgets may format with a thousands separator
+ let sample = "\
+Opus 4.5 · Claude Max
+
+Current session
+█████████████░░░░░░░ 18% used
+
+Extra usage
+███████████░░░░░░░░░ 55% used
+$1,234.56 / $2,000.00 spent
+";
+ let parsed = parse_usage_output(sample).expect("should parse");
+ let extra = parsed.extra_usage.expect("extra usage should be present");
+ assert_eq!(extra.used_credits, Some(123_456.0));
+ assert_eq!(extra.monthly_limit, Some(200_000.0));
+ }
+
+ #[test]
+ fn parse_usage_output_builds_buckets() {
+ let sample = "\
+Opus 4.5 · Claude Max
+
+Current session
+█████████████░░░░░░░ 18% used
+Resets 4:59pm (America/New_York)
+
+Current week (all models)
+█████████░░░░░░░░░░░ 36% used
+Resets Dec 25 at 2:59pm
+";
+ let parsed = parse_usage_output(sample).expect("should parse");
+ assert_eq!(parsed.five_hour.as_ref().unwrap().utilization, Some(18.0));
+ assert_eq!(parsed.seven_day.as_ref().unwrap().utilization, Some(36.0));
+ }
+}
diff --git a/src-tauri/src/services/interactive_runner.rs b/src-tauri/src/services/interactive_runner.rs
new file mode 100644
index 0000000..a62760d
--- /dev/null
+++ b/src-tauri/src/services/interactive_runner.rs
@@ -0,0 +1,322 @@
+//! Generic PTY runner for interactive CLI commands.
+//!
+//! Ported from ClaudeBar's `InteractiveRunner.swift`. Spawns a child process
+//! inside a pseudo-terminal so React/Ink-based TUI applications (like the
+//! Claude Code CLI) run correctly, collects output, and auto-responds to
+//! known prompts. Exits when the child finishes, the total timeout elapses,
+//! or the idle timeout has been reached after meaningful output was seen.
+
+use std::io::{Read, Write};
+use std::path::PathBuf;
+use std::sync::{Arc, Mutex};
+use std::thread;
+use std::time::{Duration, Instant};
+
+use anyhow::{Context, Result};
+use portable_pty::{native_pty_system, CommandBuilder, PtySize};
+
+/// Configuration for a single interactive run.
+pub struct RunOptions {
+ pub args: Vec,
+ /// Additional text to write to the child's stdin after launch (e.g. slash
+ /// commands typed into a REPL). A trailing `\r` is appended automatically.
+ pub input: Option,
+ /// Hard cap on total runtime.
+ pub timeout: Duration,
+ /// If no new meaningful data arrives for this long AND we already have
+ /// meaningful output buffered, terminate the child and return.
+ pub idle_timeout: Duration,
+ pub working_directory: Option,
+ /// Environment variable names to strip from the child's environment.
+ pub env_exclusions: Vec,
+ /// Substring → response pairs. When the buffer contains the substring,
+ /// the response is written to stdin and the prompt is marked as answered.
+ pub auto_responses: Vec<(String, String)>,
+}
+
+impl Default for RunOptions {
+ fn default() -> Self {
+ Self {
+ args: Vec::new(),
+ input: None,
+ timeout: Duration::from_secs(20),
+ idle_timeout: Duration::from_secs(3),
+ working_directory: None,
+ env_exclusions: Vec::new(),
+ auto_responses: Vec::new(),
+ }
+ }
+}
+
+/// Run an interactive CLI command inside a PTY and capture its output as raw
+/// bytes (including ANSI escape sequences — feed the result into a terminal
+/// renderer if you want clean text).
+pub fn run_interactive(binary: &std::path::Path, options: RunOptions) -> Result> {
+ let pty_system = native_pty_system();
+ let pair = pty_system
+ .openpty(PtySize {
+ rows: 50,
+ cols: 160,
+ pixel_width: 0,
+ pixel_height: 0,
+ })
+ .context("Failed to open PTY")?;
+
+ let mut cmd = CommandBuilder::new(binary);
+ for arg in &options.args {
+ cmd.arg(arg);
+ }
+ if let Some(cwd) = &options.working_directory {
+ cmd.cwd(cwd);
+ }
+
+ // Build an environment for the child. portable-pty inherits the current
+ // process environment by default only if we don't set any variables, so
+ // we explicitly copy everything except the excluded vars.
+ for (key, value) in std::env::vars() {
+ if options.env_exclusions.iter().any(|k| k == &key) {
+ continue;
+ }
+ cmd.env(key, value);
+ }
+
+ let mut child = pair
+ .slave
+ .spawn_command(cmd)
+ .context("Failed to spawn command in PTY")?;
+
+ // We don't need the slave handle after spawning the child.
+ drop(pair.slave);
+
+ let mut writer = pair
+ .master
+ .take_writer()
+ .context("Failed to take PTY writer")?;
+ let mut reader = pair
+ .master
+ .try_clone_reader()
+ .context("Failed to clone PTY reader")?;
+
+ // Let the child initialize (matches ClaudeBar's 400ms pause).
+ thread::sleep(Duration::from_millis(400));
+
+ // Send the input command, if any. React/Ink apps expect a terminal-style
+ // carriage return rather than a newline.
+ if let Some(ref input) = options.input {
+ let trimmed = input.trim();
+ if !trimmed.is_empty() {
+ writer
+ .write_all(trimmed.as_bytes())
+ .context("Failed to send input")?;
+ writer
+ .write_all(b"\r")
+ .context("Failed to send input terminator")?;
+ writer.flush().ok();
+ }
+ }
+
+ // Buffer is shared between the reader thread and the main loop so we can
+ // inspect it for auto-response prompts without racing the reader.
+ let buffer: Arc>> = Arc::new(Mutex::new(Vec::new()));
+ let buffer_reader = Arc::clone(&buffer);
+ let reader_done = Arc::new(Mutex::new(false));
+ let reader_done_clone = Arc::clone(&reader_done);
+
+ thread::spawn(move || {
+ let mut chunk = [0u8; 4096];
+ loop {
+ match reader.read(&mut chunk) {
+ Ok(0) => break,
+ Ok(n) => {
+ if let Ok(mut buf) = buffer_reader.lock() {
+ buf.extend_from_slice(&chunk[..n]);
+ }
+ }
+ Err(_) => break,
+ }
+ }
+ if let Ok(mut done) = reader_done_clone.lock() {
+ *done = true;
+ }
+ });
+
+ let deadline = Instant::now() + options.timeout;
+ let mut last_meaningful_at = Instant::now();
+ let mut last_len = 0usize;
+ let mut responded: Vec = vec![false; options.auto_responses.len()];
+
+ loop {
+ // Snapshot the buffer for inspection
+ let snapshot = match buffer.lock() {
+ Ok(b) => b.clone(),
+ Err(_) => break,
+ };
+
+ // Track meaningful data
+ if snapshot.len() > last_len {
+ let new_data = &snapshot[last_len..];
+ if has_meaningful_content(new_data) {
+ last_meaningful_at = Instant::now();
+ }
+ last_len = snapshot.len();
+ }
+
+ // Auto-respond to prompts we haven't already answered
+ for (idx, (prompt, response)) in options.auto_responses.iter().enumerate() {
+ if responded[idx] {
+ continue;
+ }
+ if contains_subslice(&snapshot, prompt.as_bytes()) {
+ if writer.write_all(response.as_bytes()).is_ok() {
+ writer.flush().ok();
+ }
+ responded[idx] = true;
+ last_meaningful_at = Instant::now();
+ }
+ }
+
+ // Exit conditions
+ let child_done = child.try_wait().ok().flatten().is_some();
+ if child_done {
+ break;
+ }
+
+ let reader_done_flag = reader_done.lock().map(|d| *d).unwrap_or(false);
+ if reader_done_flag {
+ break;
+ }
+
+ if Instant::now() >= deadline {
+ log::debug!("InteractiveRunner: hard timeout hit");
+ break;
+ }
+
+ // Idle exit: we have meaningful content and nothing new has arrived
+ // in `idle_timeout`. Don't exit prematurely when the buffer is still
+ // empty (the child might still be starting up).
+ if has_meaningful_content(&snapshot)
+ && Instant::now().duration_since(last_meaningful_at) >= options.idle_timeout
+ {
+ break;
+ }
+
+ thread::sleep(Duration::from_millis(60));
+ }
+
+ // Force-stop the child if it's still running
+ let _ = child.kill();
+ let _ = child.wait();
+
+ // One last read of whatever is in the buffer
+ let final_buffer = buffer.lock().map(|b| b.clone()).unwrap_or_default();
+ Ok(final_buffer)
+}
+
+/// Returns true if the buffer contains the given sub-slice. Linear scan is
+/// fine here since buffers stay small (a few KB of terminal output).
+fn contains_subslice(haystack: &[u8], needle: &[u8]) -> bool {
+ if needle.is_empty() || needle.len() > haystack.len() {
+ return false;
+ }
+ haystack.windows(needle.len()).any(|w| w == needle)
+}
+
+/// Returns true if the data contains anything beyond ANSI escape sequences.
+/// Ported from ClaudeBar's `hasMeaningfulContent` — the goal is to avoid
+/// resetting the idle timer on cursor movement, title updates, and other
+/// non-visible noise that the terminal keeps emitting while rendering.
+fn has_meaningful_content(data: &[u8]) -> bool {
+ // Decode as UTF-8; if it fails, treat as meaningful (binary data).
+ let Ok(text) = std::str::from_utf8(data) else {
+ return !data.is_empty();
+ };
+
+ let mut stripped = String::with_capacity(text.len());
+ let mut chars = text.chars().peekable();
+
+ while let Some(ch) = chars.next() {
+ if ch != '\x1b' {
+ stripped.push(ch);
+ continue;
+ }
+ // We saw ESC; classify the sequence
+ let Some(&next) = chars.peek() else {
+ continue;
+ };
+
+ match next {
+ '[' => {
+ // CSI sequence: ESC [
+ chars.next();
+ for c in chars.by_ref() {
+ if c.is_ascii_alphabetic() {
+ break;
+ }
+ }
+ }
+ '(' | ')' => {
+ // Charset designator: ESC (
+ chars.next();
+ chars.next();
+ }
+ ']' => {
+ // OSC sequence: ESC ] ... BEL or ESC ] ... ESC \
+ chars.next();
+ while let Some(&c) = chars.peek() {
+ chars.next();
+ if c == '\x07' {
+ break;
+ }
+ if c == '\x1b' {
+ // Skip the terminating backslash
+ if let Some(&b) = chars.peek() {
+ if b == '\\' {
+ chars.next();
+ }
+ }
+ break;
+ }
+ }
+ }
+ _ => {
+ // Unknown escape — just drop the ESC
+ }
+ }
+ }
+
+ stripped
+ .chars()
+ .any(|c| !c.is_whitespace() && c != '\u{07}')
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn meaningful_content_detects_plain_text() {
+ assert!(has_meaningful_content(b"hello"));
+ }
+
+ #[test]
+ fn meaningful_content_ignores_csi_only() {
+ assert!(!has_meaningful_content(b"\x1b[2J\x1b[H"));
+ }
+
+ #[test]
+ fn meaningful_content_ignores_osc_title() {
+ assert!(!has_meaningful_content(b"\x1b]0;title\x07"));
+ }
+
+ #[test]
+ fn meaningful_content_detects_text_with_ansi() {
+ assert!(has_meaningful_content(b"\x1b[32mhello\x1b[0m"));
+ }
+
+ #[test]
+ fn contains_subslice_works() {
+ assert!(contains_subslice(b"hello world", b"world"));
+ assert!(!contains_subslice(b"hello", b"world"));
+ assert!(!contains_subslice(b"ab", b"abc"));
+ }
+}
diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs
index bfc547f..1d6d804 100644
--- a/src-tauri/src/services/mod.rs
+++ b/src-tauri/src/services/mod.rs
@@ -10,9 +10,12 @@ mod notification_settings_service;
pub mod session_cache;
pub mod session_watcher;
mod stats_service;
+mod claude_cli_probe;
mod claude_credentials;
mod insights_service;
+mod interactive_runner;
mod subscription_usage_service;
+mod terminal_renderer;
pub mod time_range;
mod tools_service;
mod trends_service;
diff --git a/src-tauri/src/services/subscription_usage_service.rs b/src-tauri/src/services/subscription_usage_service.rs
index 791a790..e7c4834 100644
--- a/src-tauri/src/services/subscription_usage_service.rs
+++ b/src-tauri/src/services/subscription_usage_service.rs
@@ -77,44 +77,36 @@ enum AuthOrError {
pub struct SubscriptionUsageService;
impl SubscriptionUsageService {
+ /// Fetch subscription usage. Prefers the CLI path (`claude /usage` inside
+ /// a PTY) since it requires no direct Keychain access and matches what
+ /// ClaudeBar does by default. Falls back to the OAuth API only if the CLI
+ /// can't run (binary missing, PTY spawn failure, parse failure, etc).
pub async fn fetch_usage() -> Result {
- // Derive subscription badge once — applies to all result paths (API + CLI).
- let subscription_badge = claude_credentials::load_credentials()
- .as_ref()
- .and_then(Self::parse_subscription_badge);
-
- let mut result = match Self::fetch_via_api().await {
- Ok(r) if !r.needs_login => r,
- Ok(api_result) => {
- // needs_login from API — try CLI fallback before giving up
- log::debug!("API probe requires login, trying CLI fallback...");
- match Self::fetch_via_cli().await {
- Ok(cli_result) => cli_result,
- Err(e) => {
- log::debug!("CLI fallback also failed: {}", e);
- api_result
- }
- }
+ // Primary: CLI probe. No Keychain access — `claude` itself reads the
+ // stored credentials. `cli.usage` is `None` for account types that
+ // don't expose subscription quotas (e.g. pay-per-use API billing),
+ // in which case we propagate the badge to the frontend which then
+ // renders a dedicated empty state instead of a gauge grid.
+ match super::claude_cli_probe::ClaudeCliProbe::fetch_usage().await {
+ Ok(cli) => {
+ return Ok(SubscriptionUsageResult {
+ needs_login: false,
+ usage: cli.usage,
+ error: None,
+ subscription_type: cli.subscription_badge,
+ });
}
- Err(api_err) => {
- // API error — try CLI fallback
- log::warn!("API probe failed: {}, trying CLI fallback...", api_err);
- match Self::fetch_via_cli().await {
- Ok(cli_result) => cli_result,
- Err(cli_err) => {
- log::warn!("CLI fallback also failed: {}", cli_err);
- return Err(api_err);
- }
- }
+ Err(cli_err) => {
+ log::warn!(
+ "CLI usage probe failed, falling back to OAuth API: {}",
+ cli_err
+ );
}
- };
-
- // Ensure subscription badge is present on all paths
- if result.subscription_type.is_none() {
- result.subscription_type = subscription_badge;
}
- Ok(result)
+ // Fallback: API path. This DOES touch the Keychain (via
+ // load_credentials) because we need the OAuth access token.
+ Self::fetch_via_api().await
}
// ---------------------------------------------------------------
@@ -399,129 +391,6 @@ impl SubscriptionUsageService {
})
}
- // ---------------------------------------------------------------
- // CLI fallback
- // ---------------------------------------------------------------
-
- async fn fetch_via_cli() -> Result {
- let claude_path = which::which("claude")
- .context("Claude CLI binary not found in PATH")?;
-
- log::debug!("CLI fallback: using {}", claude_path.display());
-
- // Strip CLAUDE_CODE_OAUTH_TOKEN from env to force stored credentials
- // (setup-tokens only have inference scope, not usage scope)
- let env_vars: Vec<(String, String)> = std::env::vars()
- .filter(|(k, _)| k != "CLAUDE_CODE_OAUTH_TOKEN")
- .collect();
-
- let output = tokio::process::Command::new(&claude_path)
- .args(["/usage", "--output", "json", "--allowed-tools", ""])
- .env_clear()
- .envs(env_vars)
- .stdout(std::process::Stdio::piped())
- .stderr(std::process::Stdio::piped())
- .output()
- .await
- .context("Failed to execute claude /usage")?;
-
- if !output.status.success() {
- let stderr = String::from_utf8_lossy(&output.stderr);
- anyhow::bail!("claude /usage failed (exit {}): {}", output.status, stderr);
- }
-
- let stdout = String::from_utf8_lossy(&output.stdout);
-
- // Try parsing as JSON first (--output json may work)
- if let Ok(api_resp) = serde_json::from_str::(&stdout) {
- return Ok(SubscriptionUsageResult {
- needs_login: false,
- usage: Some(Self::convert_response(api_resp)),
- error: None,
- subscription_type: None,
- });
- }
-
- // Try parsing as text output (fallback)
- match Self::parse_cli_text_output(&stdout) {
- Some(usage) => Ok(SubscriptionUsageResult {
- needs_login: false,
- usage: Some(usage),
- error: None,
- subscription_type: None,
- }),
- None => {
- log::warn!("CLI fallback: could not parse output: {}", &stdout[..stdout.len().min(500)]);
- anyhow::bail!("Failed to parse claude /usage output")
- }
- }
- }
-
- /// Best-effort parser for `claude /usage` text output.
- /// Looks for percentage patterns like "45.2% used" or "45%" and reset times.
- fn parse_cli_text_output(text: &str) -> Option {
- use crate::types::{ExtraUsage, UsageBucket};
-
- // Strip ANSI escape codes
- let clean = strip_ansi(text);
- let lines: Vec<&str> = clean.lines().collect();
-
- let mut five_hour: Option = None;
- let mut seven_day: Option = None;
- let mut seven_day_opus: Option = None;
- let mut seven_day_sonnet: Option = None;
- let mut extra_usage: Option = None;
-
- let mut i = 0;
- while i < lines.len() {
- let line = lines[i].trim().to_lowercase();
-
- if line.contains("session") && line.contains("limit") || line.contains("5-hour") || line.contains("five") {
- if let Some((util, resets)) = find_usage_in_nearby_lines(&lines, i) {
- five_hour = Some(UsageBucket { utilization: Some(util), resets_at: resets });
- }
- } else if line.contains("opus") {
- if let Some((util, resets)) = find_usage_in_nearby_lines(&lines, i) {
- seven_day_opus = Some(UsageBucket { utilization: Some(util), resets_at: resets });
- }
- } else if line.contains("sonnet") {
- if let Some((util, resets)) = find_usage_in_nearby_lines(&lines, i) {
- seven_day_sonnet = Some(UsageBucket { utilization: Some(util), resets_at: resets });
- }
- } else if (line.contains("weekly") || line.contains("7-day") || line.contains("seven"))
- && !line.contains("opus") && !line.contains("sonnet")
- {
- if let Some((util, resets)) = find_usage_in_nearby_lines(&lines, i) {
- seven_day = Some(UsageBucket { utilization: Some(util), resets_at: resets });
- }
- } else if line.contains("extra") || line.contains("overage") || line.contains("pay") {
- if let Some((util, _)) = find_usage_in_nearby_lines(&lines, i) {
- extra_usage = Some(ExtraUsage {
- is_enabled: true,
- utilization: Some(util),
- used_credits: None,
- monthly_limit: None,
- });
- }
- }
-
- i += 1;
- }
-
- // Only return if we found at least one bucket
- if five_hour.is_some() || seven_day.is_some() || seven_day_opus.is_some() || seven_day_sonnet.is_some() {
- Some(SubscriptionUsageResponse {
- five_hour,
- seven_day,
- seven_day_opus,
- seven_day_sonnet,
- extra_usage,
- })
- } else {
- None
- }
- }
-
// ---------------------------------------------------------------
// Response conversion
// ---------------------------------------------------------------
@@ -531,7 +400,10 @@ impl SubscriptionUsageService {
let convert_bucket = |b: ApiUsageBucket| UsageBucket {
utilization: b.utilization,
- resets_at: b.resets_at,
+ // Format the ISO timestamp into a display string so the frontend
+ // doesn't need to parse dates — the CLI path already delivers
+ // display-ready text, and both paths must look the same.
+ resets_at: b.resets_at.as_deref().and_then(format_api_reset_time),
};
SubscriptionUsageResponse {
@@ -544,57 +416,127 @@ impl SubscriptionUsageService {
utilization: e.utilization,
used_credits: e.used_credits,
monthly_limit: e.monthly_limit,
+ // The OAuth API doesn't currently return a reset field for
+ // extra_usage; leave it None and the UI will hide the row.
+ resets_at: None,
}),
}
}
}
-// --- Helpers ---
-
-/// Strip ANSI escape codes from text.
-fn strip_ansi(text: &str) -> String {
- let mut result = String::with_capacity(text.len());
- let mut chars = text.chars().peekable();
- while let Some(ch) = chars.next() {
- if ch == '\x1b' {
- // Skip until we find the terminating letter
- if chars.peek() == Some(&'[') {
- chars.next();
- while let Some(&c) = chars.peek() {
- chars.next();
- if c.is_ascii_alphabetic() {
- break;
- }
- }
- }
- } else {
- result.push(ch);
+/// Format an ISO 8601 timestamp into a compact human-readable "Resets in ..."
+/// style string, matching the shape of the CLI probe output. Returns `None`
+/// if the timestamp is already in the past or can't be parsed.
+///
+/// Examples:
+/// - 45 minutes from now → `"in 45m"`
+/// - 2 hours 15 minutes from now → `"in 2h 15m"`
+/// - 3 days from now → `"in 3d"`
+/// - 30 days from now → `"Jan 15"` (or `"Jan 15, 2027"` if year differs)
+fn format_api_reset_time(iso: &str) -> Option {
+ use chrono::{DateTime, Datelike, Local, Utc};
+
+ let parsed = DateTime::parse_from_rfc3339(iso).ok()?;
+ let now = Utc::now();
+ let target_utc = parsed.with_timezone(&Utc);
+ let delta = target_utc.signed_duration_since(now);
+
+ if delta.num_seconds() <= 0 {
+ return None;
+ }
+
+ let total_minutes = delta.num_minutes();
+ let total_hours = delta.num_hours();
+ let total_days = delta.num_days();
+
+ // < 1 hour → "in Xm"
+ if total_hours < 1 {
+ return Some(format!("in {}m", total_minutes.max(1)));
+ }
+
+ // < 24 hours → "in Xh" or "in Xh Ym"
+ if total_days < 1 {
+ let minutes_remainder = total_minutes - total_hours * 60;
+ if minutes_remainder == 0 {
+ return Some(format!("in {}h", total_hours));
}
+ return Some(format!("in {}h {}m", total_hours, minutes_remainder));
}
- result
-}
-/// Extract a percentage value from text (e.g. "45.2% used" → 45.2).
-fn extract_percentage(text: &str) -> Option {
- let text = text.trim();
- for word in text.split_whitespace() {
- let word = word.trim_end_matches('%');
- if let Ok(val) = word.parse::() {
- if (0.0..=100.0).contains(&val) {
- return Some(val);
- }
+ // < 7 days → "in Xd" or "in Xd Yh"
+ if total_days < 7 {
+ let hours_remainder = total_hours - total_days * 24;
+ if hours_remainder == 0 {
+ return Some(format!("in {}d", total_days));
}
+ return Some(format!("in {}d {}h", total_days, hours_remainder));
+ }
+
+ // >= 7 days → absolute date in local timezone, e.g. "Jan 15" or "Jan 15, 2027"
+ let local = target_utc.with_timezone(&Local);
+ let now_local = now.with_timezone(&Local);
+ if local.year() == now_local.year() {
+ Some(local.format("%b %-d").to_string())
+ } else {
+ Some(local.format("%b %-d, %Y").to_string())
}
- None
}
-/// Search nearby lines (current + next 5) for a percentage value.
-fn find_usage_in_nearby_lines(lines: &[&str], start: usize) -> Option<(f64, Option)> {
- let end = (start + 6).min(lines.len());
- for line in &lines[start..end] {
- if let Some(pct) = extract_percentage(line) {
- return Some((pct, None));
- }
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use chrono::{Duration, Utc};
+
+ fn iso_offset(duration: Duration) -> String {
+ (Utc::now() + duration)
+ .to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
+ }
+
+ #[test]
+ fn format_api_reset_time_minutes() {
+ let s = format_api_reset_time(&iso_offset(Duration::minutes(15))).unwrap();
+ // Allow slight clock drift between iso_offset() and format_api_reset_time()
+ assert!(s == "in 15m" || s == "in 14m", "got: {}", s);
+ }
+
+ #[test]
+ fn format_api_reset_time_hours_exact() {
+ let s = format_api_reset_time(&iso_offset(Duration::hours(3))).unwrap();
+ // Depending on rounding, might be "in 3h" or "in 2h 59m"
+ assert!(s == "in 3h" || s == "in 2h 59m", "got: {}", s);
+ }
+
+ #[test]
+ fn format_api_reset_time_hours_and_minutes() {
+ let s = format_api_reset_time(&iso_offset(
+ Duration::hours(2) + Duration::minutes(15),
+ ))
+ .unwrap();
+ assert!(s.starts_with("in 2h 1"), "got: {}", s);
+ }
+
+ #[test]
+ fn format_api_reset_time_days() {
+ let s = format_api_reset_time(&iso_offset(Duration::days(3))).unwrap();
+ assert!(s == "in 3d" || s == "in 2d 23h", "got: {}", s);
+ }
+
+ #[test]
+ fn format_api_reset_time_far_future_uses_abs_date() {
+ let s = format_api_reset_time(&iso_offset(Duration::days(30))).unwrap();
+ // Should be an abbreviated month + day, NOT "in 30d"
+ assert!(!s.starts_with("in "), "got: {}", s);
+ }
+
+ #[test]
+ fn format_api_reset_time_past_returns_none() {
+ let s = format_api_reset_time(&iso_offset(Duration::seconds(-10)));
+ assert!(s.is_none());
+ }
+
+ #[test]
+ fn format_api_reset_time_invalid_returns_none() {
+ assert!(format_api_reset_time("not-a-date").is_none());
}
- None
}
+
diff --git a/src-tauri/src/services/terminal_renderer.rs b/src-tauri/src/services/terminal_renderer.rs
new file mode 100644
index 0000000..f336f35
--- /dev/null
+++ b/src-tauri/src/services/terminal_renderer.rs
@@ -0,0 +1,46 @@
+//! Renders raw terminal output (containing ANSI escape sequences) into
+//! clean, line-oriented text by feeding it through a VT100 emulator.
+//!
+//! This is the Rust equivalent of ClaudeBar's `TerminalRenderer.swift`, which
+//! wraps SwiftTerm to handle cursor movement, screen clearing, and other
+//! control sequences that would otherwise corrupt captured PTY output.
+
+/// Default dimensions used for the headless terminal. Matches ClaudeBar so
+/// we parse the same rendered layout.
+const DEFAULT_ROWS: u16 = 50;
+const DEFAULT_COLS: u16 = 160;
+
+pub struct TerminalRenderer {
+ rows: u16,
+ cols: u16,
+}
+
+impl TerminalRenderer {
+ pub fn new() -> Self {
+ Self {
+ rows: DEFAULT_ROWS,
+ cols: DEFAULT_COLS,
+ }
+ }
+
+ /// Feed raw bytes through a vt100 parser and return the rendered screen
+ /// text, trimmed of trailing empty rows.
+ pub fn render(&self, raw: &[u8]) -> String {
+ let mut parser = vt100::Parser::new(self.rows, self.cols, 0);
+ parser.process(raw);
+
+ let contents = parser.screen().contents();
+
+ // Trim trailing empty lines so the parser doesn't have to walk empty
+ // rows. `contents()` pads every row to `cols`, so internal empty rows
+ // are just newlines.
+ let trimmed_end = contents.trim_end_matches(['\n', ' ']);
+ trimmed_end.to_string()
+ }
+}
+
+impl Default for TerminalRenderer {
+ fn default() -> Self {
+ Self::new()
+ }
+}
diff --git a/src-tauri/src/types/subscription_usage.rs b/src-tauri/src/types/subscription_usage.rs
index 41b20f5..1b82b94 100644
--- a/src-tauri/src/types/subscription_usage.rs
+++ b/src-tauri/src/types/subscription_usage.rs
@@ -28,6 +28,8 @@ pub struct ExtraUsage {
pub used_credits: Option,
/// Monthly limit in minor units (cents)
pub monthly_limit: Option,
+ /// Pre-formatted reset string (e.g. "Jan 1, 2026"). None if not available.
+ pub resets_at: Option,
}
/// Full subscription usage response from the OAuth API