diff --git a/Cargo.lock b/Cargo.lock index 3e086d1..385480e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -141,6 +141,8 @@ dependencies = [ "dirs", "log", "notify", + "portable-pty", + "regex", "reqwest", "security-framework", "serde", @@ -163,6 +165,7 @@ dependencies = [ "toml 1.1.2+spec-1.1.0", "typeshare", "uuid", + "vt100", "which", ] @@ -550,7 +553,7 @@ checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" dependencies = [ "borsh-derive", "bytes", - "cfg_aliases", + "cfg_aliases 0.2.1", ] [[package]] @@ -766,6 +769,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + [[package]] name = "cfg_aliases" version = "0.2.1" @@ -1328,7 +1337,7 @@ dependencies = [ "rustc_version", "toml 0.9.12+spec-1.1.0", "vswhom", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -1493,6 +1502,17 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "filetime" version = "0.2.27" @@ -3071,6 +3091,18 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases 0.1.1", + "libc", +] + [[package]] name = "nodrop" version = "0.1.14" @@ -3868,6 +3900,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "portable-pty" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg 0.10.1", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -4080,7 +4133,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" dependencies = [ "bytes", - "cfg_aliases", + "cfg_aliases 0.2.1", "pin-project-lite", "quinn-proto", "quinn-udp", @@ -4121,7 +4174,7 @@ version = "0.5.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" dependencies = [ - "cfg_aliases", + "cfg_aliases 0.2.1", "libc", "once_cell", "socket2", @@ -4941,6 +4994,17 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "serial2" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e66ab7ee258c6456796c6098e1b53a5baa1a5e0637347de59ddb44ee8e20be6e" +dependencies = [ + "cfg-if", + "libc", + "winapi", +] + [[package]] name = "serialize-to-javascript" version = "0.1.2" @@ -5029,6 +5093,22 @@ dependencies = [ "uuid", ] +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" @@ -6647,6 +6727,12 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -6776,6 +6862,27 @@ dependencies = [ "libc", ] +[[package]] +name = "vt100" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ff75fb8fa83e609e685106df4faeffdf3a735d3c74ebce97ec557d5d36fd9" +dependencies = [ + "itoa", + "unicode-width", + "vte", +] + +[[package]] +name = "vte" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" +dependencies = [ + "arrayvec", + "memchr", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -7676,6 +7783,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "winreg" version = "0.55.0" diff --git a/packages/ui/src/modules/usage/components/api-billing-notice/index.tsx b/packages/ui/src/modules/usage/components/api-billing-notice/index.tsx new file mode 100644 index 0000000..1cefd7d --- /dev/null +++ b/packages/ui/src/modules/usage/components/api-billing-notice/index.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { Receipt } from "lucide-react"; +import { Card, CardContent } from "@/components/ui/card"; + +export function ApiBillingNotice() { + return ( +
+ + +
+ +
+
+

Pay-as-you-go account

+

+ This account uses pay-per-use API billing instead of a Claude + subscription, so there are no fixed quotas to show. Check your + provider's dashboard for spend and usage details. +

+
+
+
+
+ ); +} diff --git a/packages/ui/src/modules/usage/components/extra-usage-card/index.tsx b/packages/ui/src/modules/usage/components/extra-usage-card/index.tsx index 4ec0a16..5f1efec 100644 --- a/packages/ui/src/modules/usage/components/extra-usage-card/index.tsx +++ b/packages/ui/src/modules/usage/components/extra-usage-card/index.tsx @@ -1,67 +1,23 @@ "use client"; -import { cn } from "@/lib/utils"; +import { EChart } from "@/components/echarts"; import type { ExtraUsageCardProps } from "./types"; - -function formatCents(cents: number): string { - return `$${(cents / 100).toFixed(2)}`; -} +import { useExtraUsageCard } from "./use-service"; export function ExtraUsageCard({ extra }: ExtraUsageCardProps) { - const percent = Math.round(extra.utilization ?? 0); - const barColor = - percent >= 90 - ? "bg-red-500" - : percent >= 70 - ? "bg-amber-500" - : "bg-chart-1"; - const textColor = - percent >= 90 - ? "text-red-600 dark:text-red-400" - : percent >= 70 - ? "text-amber-600 dark:text-amber-400" - : "text-muted-foreground"; + const { option, resetText } = useExtraUsageCard(extra); return ( -
- {/* Progress bar */} -
-
-
-

- {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