From 4ceb88f03c76177e56a9459997999877a364d231 Mon Sep 17 00:00:00 2001 From: NessZerra <90105158+Finesssee@users.noreply.github.com> Date: Sat, 19 Sep 2026 21:34:59 +0700 Subject: [PATCH 1/8] Add transient provider inventory plumbing --- .../src-tauri/src/commands/bridge.rs | 24 +++++ .../src-tauri/src/commands/provider_detail.rs | 3 + .../src-tauri/src/commands/providers.rs | 1 + .../src-tauri/src/commands/tests.rs | 44 +++++++- apps/desktop-tauri/src-tauri/src/powertoys.rs | 2 + .../src-tauri/src/tray_bridge.rs | 1 + .../src-tauri/src/usage_metric.rs | 1 + .../src/components/MenuCardDetails.tsx | 51 ++++++++- .../providers/sections/UsageSection.test.tsx | 23 ++++ .../providers/sections/UsageSection.tsx | 33 +++++- apps/desktop-tauri/src/types/bridge.ts | 11 ++ rust/src/cli/usage.rs | 100 +++++++++++++++++- rust/src/core/usage_snapshot.rs | 50 +++++++++ 13 files changed, 338 insertions(+), 6 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index 8cd5e91924..1a86eab7b4 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -178,6 +178,17 @@ pub struct SubscriptionMetadataSnapshot { pub renews_at: Option, } +/// Display-only provider inventory. Redemption identifiers never cross the +/// bridge. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderInventoryItemSnapshot { + pub id: String, + pub title: String, + pub available_count: u32, + pub next_expires_at: Option, +} + /// A frontend-friendly snapshot of one provider's usage data. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -200,6 +211,8 @@ pub struct ProviderUsageSnapshot { pub tertiary: Option, #[serde(default)] pub extra_rate_windows: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub inventory: Vec, #[serde(default)] pub cost: Option, #[serde(default)] @@ -384,6 +397,16 @@ impl ProviderUsageSnapshot { window: RateWindowSnapshot::from_rate_window(&extra.window), }) .collect(), + inventory: result + .inventory + .iter() + .map(|item| ProviderInventoryItemSnapshot { + id: item.id.clone(), + title: item.title.clone(), + available_count: item.available_count, + next_expires_at: item.next_expires_at.map(|date| date.to_rfc3339()), + }) + .collect(), cost: result.cost.as_ref().map(|c| CostSnapshotBridge { used: c.used, limit: c.limit, @@ -461,6 +484,7 @@ impl ProviderUsageSnapshot { tertiary: None, tertiary_label: None, extra_rate_windows: Vec::new(), + inventory: Vec::new(), cost: None, plan_name: None, account_email: None, diff --git a/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs b/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs index 7690a5b2aa..18b1def39b 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs @@ -26,6 +26,7 @@ pub struct ProviderDetail { pub model_specific: Option, pub tertiary: Option, pub extra_rate_windows: Vec, + pub inventory: Vec, // Cost / pace. pub cost: Option, @@ -91,6 +92,7 @@ pub(crate) fn build_provider_detail(provider_id: &str) -> Result::from_timestamp(1_900_000_000, 0).unwrap(); + let result = ProviderFetchResult::new( + codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(12.0)), + "web", + ) + .with_inventory_item(ProviderInventoryItem { + id: "reset-credits".to_string(), + title: "Limit Reset Credits".to_string(), + available_count: 2, + next_expires_at: Some(expiry), + }); + let metadata = instantiate_provider(ProviderId::Grok).metadata().clone(); + let snapshot = + ProviderUsageSnapshot::from_fetch_result(ProviderId::Grok, &metadata, &result, None); + + assert_eq!(snapshot.inventory.len(), 1); + assert_eq!(snapshot.inventory[0].available_count, 2); + assert_eq!( + snapshot.inventory[0].next_expires_at.as_deref(), + Some("2030-03-17T17:46:40+00:00") + ); + let serialized = serde_json::to_string(&snapshot).unwrap(); + assert!(serialized.contains("reset-credits")); + assert!(!serialized.contains("coupon-token-secret")); +} + #[test] fn provider_cache_is_fresh_inside_stale_window() { assert!(super::is_provider_cache_fresh( @@ -1057,6 +1085,7 @@ fn provider_cache_upsert_replaces_existing_provider() { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(10.0)), cost: None, wayfinder_usage: None, + inventory: Vec::new(), source_label: "CLI".to_string(), has_successful_claude_cli_quota: false, pace_authoritative: true, @@ -1083,6 +1112,7 @@ fn provider_cache_prunes_disabled_providers() { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(10.0)), cost: None, wayfinder_usage: None, + inventory: Vec::new(), source_label: "CLI".to_string(), has_successful_claude_cli_quota: false, pace_authoritative: true, @@ -1116,6 +1146,7 @@ fn hiding_codex_spark_rows_preserves_other_extra_usage() { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(10.0)), cost: None, wayfinder_usage: None, + inventory: Vec::new(), source_label: "CLI".to_string(), has_successful_claude_cli_quota: false, pace_authoritative: true, @@ -1149,6 +1180,7 @@ fn claude_transient_auth_failure_preserves_first_last_good_snapshot() { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(42.0)), cost: None, wayfinder_usage: None, + inventory: Vec::new(), source_label: "OAuth".to_string(), has_successful_claude_cli_quota: false, pace_authoritative: true, @@ -1184,6 +1216,7 @@ fn codex_transient_transport_failure_helper_uses_typed_policy() { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(42.0)), cost: None, wayfinder_usage: None, + inventory: Vec::new(), source_label: "OAuth".to_string(), has_successful_claude_cli_quota: false, pace_authoritative: true, @@ -1218,6 +1251,7 @@ fn claude_repeated_auth_failure_surfaces_error() { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(42.0)), cost: None, wayfinder_usage: None, + inventory: Vec::new(), source_label: "OAuth".to_string(), has_successful_claude_cli_quota: false, pace_authoritative: true, @@ -1259,6 +1293,7 @@ fn claude_cloudflare_challenge_retains_prior_usage_while_surfaceing_guidance() { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(42.0)), cost: None, wayfinder_usage: None, + inventory: Vec::new(), source_label: "OAuth".to_string(), has_successful_claude_cli_quota: false, pace_authoritative: true, @@ -1311,6 +1346,7 @@ fn claude_cloudflare_challenge_keeps_prior_usage_when_guidance_surfaces() { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(42.0)), cost: None, wayfinder_usage: None, + inventory: Vec::new(), source_label: "Web".to_string(), has_successful_claude_cli_quota: false, pace_authoritative: true, @@ -1360,6 +1396,7 @@ fn claude_cli_parse_failure_keeps_last_good_every_time() { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(17.0)), cost: None, wayfinder_usage: None, + inventory: Vec::new(), source_label: "CLI".to_string(), has_successful_claude_cli_quota: true, pace_authoritative: true, @@ -1405,6 +1442,7 @@ fn claude_hard_credentials_missing_does_not_preserve_stale() { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(17.0)), cost: None, wayfinder_usage: None, + inventory: Vec::new(), source_label: "OAuth".to_string(), has_successful_claude_cli_quota: false, pace_authoritative: true, @@ -1575,6 +1613,7 @@ fn japanese_provider_snapshot_localizes_weekly_label() { usage, cost: None, wayfinder_usage: None, + inventory: Vec::new(), source_label: "OAuth".to_string(), has_successful_claude_cli_quota: false, pace_authoritative: true, @@ -1607,6 +1646,7 @@ fn japanese_provider_snapshot_localizes_pace_reserve_description() { usage, cost: None, wayfinder_usage: None, + inventory: Vec::new(), source_label: "OAuth".to_string(), has_successful_claude_cli_quota: false, pace_authoritative: true, diff --git a/apps/desktop-tauri/src-tauri/src/powertoys.rs b/apps/desktop-tauri/src-tauri/src/powertoys.rs index 4f5ce88465..f38f080161 100644 --- a/apps/desktop-tauri/src-tauri/src/powertoys.rs +++ b/apps/desktop-tauri/src-tauri/src/powertoys.rs @@ -195,6 +195,7 @@ mod tests { tertiary: None, tertiary_label: None, extra_rate_windows: Vec::new(), + inventory: Vec::new(), cost: None, plan_name: Some("Team".to_string()), account_email: Some("dev@example.com".to_string()), @@ -243,6 +244,7 @@ mod tests { tertiary: None, tertiary_label: None, extra_rate_windows: Vec::new(), + inventory: Vec::new(), cost: None, plan_name: None, account_email: None, diff --git a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs index 00ba7aff36..0ec2e19b85 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs @@ -1080,6 +1080,7 @@ mod tests { }), tertiary_label: None, extra_rate_windows: Vec::new(), + inventory: Vec::new(), cost: cost.map(|(used, limit)| crate::commands::CostSnapshotBridge { used, limit: Some(limit), diff --git a/apps/desktop-tauri/src-tauri/src/usage_metric.rs b/apps/desktop-tauri/src-tauri/src/usage_metric.rs index 73d642534c..697b94d7a2 100644 --- a/apps/desktop-tauri/src-tauri/src/usage_metric.rs +++ b/apps/desktop-tauri/src-tauri/src/usage_metric.rs @@ -296,6 +296,7 @@ mod tests { tertiary: None, tertiary_label: None, extra_rate_windows: Vec::new(), + inventory: Vec::new(), cost: None, plan_name: None, account_email: None, diff --git a/apps/desktop-tauri/src/components/MenuCardDetails.tsx b/apps/desktop-tauri/src/components/MenuCardDetails.tsx index 4d0b47d24d..8225a10817 100644 --- a/apps/desktop-tauri/src/components/MenuCardDetails.tsx +++ b/apps/desktop-tauri/src/components/MenuCardDetails.tsx @@ -3,6 +3,7 @@ import type { CostSummaryDisplayStyle, DailyCostPoint, PaceSnapshot, + ProviderInventoryItem, ProviderChartData, ProviderLocalUsageSummary, ProviderUsageSnapshot, @@ -413,6 +414,7 @@ function MetricRow({ export interface MenuCardPresence { hasMetrics: boolean; + hasInventory: boolean; hasCost: boolean; hasPace: boolean; hasCharts: boolean; @@ -456,6 +458,7 @@ export function describeCard( const localUsage = provider.error ? null : chartData?.localUsage ?? null; const wayfinderUsage = isWayfinder ? provider.wayfinderUsage : null; const hasMetrics = visibleMetrics.length > 0; + const hasInventory = !provider.error && (provider.inventory?.length ?? 0) > 0; const hasCost = !!provider.cost && (costSummaryDisplayStyle !== "hidden" || provider.cost.alwaysVisible === true); @@ -465,9 +468,16 @@ export function describeCard( !!provider.pace; const hasDetails = !provider.error && - (hasMetrics || hasCost || hasPace || hasCharts || !!localUsage || !!wayfinderUsage); + (hasMetrics || + hasInventory || + hasCost || + hasPace || + hasCharts || + !!localUsage || + !!wayfinderUsage); return { hasMetrics, + hasInventory, hasCost, hasPace, hasCharts, @@ -505,6 +515,7 @@ export default function MenuCardDetails({ const { hasMetrics, + hasInventory, hasCost, hasPace, hasCharts, @@ -540,6 +551,18 @@ export default function MenuCardDetails({ )} + {!provider.error && hasInventory && ( +
+ {provider.inventory?.map((item) => ( + + ))} +
+ )} + {wayfinderUsage && } {hasMetrics && hasCost &&
} @@ -712,3 +735,29 @@ export default function MenuCardDetails({
); } + +function InventoryItemRow({ + item, + resetTimeRelative, +}: { + item: ProviderInventoryItem; + resetTimeRelative: boolean; +}) { + const formattedExpiry = useFormattedResetTime( + item.nextExpiresAt, + null, + resetTimeRelative, + "expires", + ); + + return ( +
+ {item.title}: {item.availableCount} available + {formattedExpiry && ( + + {formattedExpiry} + + )} +
+ ); +} diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.test.tsx index 1a4807c882..c32df0cf15 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.test.tsx @@ -102,4 +102,27 @@ describe("UsageSection", () => { expect(label.parentElement).toHaveTextContent("No active 5h session"); expect(label.parentElement?.querySelector(".provider-usage-bar__track")).toBeNull(); }); + + it("renders discrete inventory without turning it into a quota bar", async () => { + const detail = provider(); + detail.session = null; + detail.extraRateWindows = []; + detail.inventory = [ + { + id: "reset-credits", + title: "Limit Reset Credits", + availableCount: 2, + nextExpiresAt: "2099-01-01T00:00:00Z", + }, + ]; + + const { container } = render( + + key} /> + , + ); + + expect(await screen.findByText(/Limit Reset Credits: 2 available/)).toBeInTheDocument(); + expect(container.querySelector(".provider-usage-bar__track")).toBeNull(); + }); }); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx index a51c294ed2..64a3fa7b6a 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx @@ -1,4 +1,5 @@ import type { + ProviderInventoryItem, ProviderDetail, RateWindowSnapshot, } from "../../../../types/bridge"; @@ -60,7 +61,8 @@ export function UsageSection({ provider, resetTimeRelative, t }: Props) { }); } - if (bars.length === 0) { + const inventory = provider.inventory ?? []; + if (bars.length === 0 && inventory.length === 0) { return null; } @@ -76,10 +78,39 @@ export function UsageSection({ provider, resetTimeRelative, t }: Props) { t={t} /> ))} + {inventory.map((item) => ( + + ))} ); } +function InventoryRow({ + item, + resetTimeRelative, +}: { + item: ProviderInventoryItem; + resetTimeRelative: boolean; +}) { + const formattedExpiry = useFormattedResetTime( + item.nextExpiresAt, + null, + resetTimeRelative, + "expires", + ); + + return ( +
+ {item.title}: {item.availableCount} available + {formattedExpiry && {formattedExpiry}} +
+ ); +} + function UsageBar({ label, rate, diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index 865625efeb..963779ae2f 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -592,6 +592,13 @@ export interface SubscriptionMetadataSnapshot { renewsAt: string | null; } +export interface ProviderInventoryItem { + id: string; + title: string; + availableCount: number; + nextExpiresAt: string | null; +} + /** Backend-classified provider availability state (camelCase serde on the bridge). */ export type ProviderStateKind = | "ready" @@ -618,6 +625,8 @@ export interface ProviderUsageSnapshot { title: string; window: RateWindowSnapshot; }>; + /** Display-only discrete provider inventory; never used as quota math. */ + inventory?: ProviderInventoryItem[]; cost: CostSnapshotBridge | null; planName: string | null; accountEmail: string | null; @@ -892,6 +901,8 @@ export interface ProviderDetail { title: string; window: RateWindowSnapshot; }>; + /** Display-only discrete provider inventory; never used as quota math. */ + inventory?: ProviderInventoryItem[]; cost: CostSnapshotBridge | null; pace: PaceSnapshot | null; diff --git a/rust/src/cli/usage.rs b/rust/src/cli/usage.rs index e0c19618d9..25b988e5c0 100755 --- a/rust/src/cli/usage.rs +++ b/rust/src/cli/usage.rs @@ -1,11 +1,13 @@ //! Usage command implementation +use chrono::{DateTime, Utc}; use clap::Args; use serde::Serialize; use crate::core::{ - CostSnapshot, FetchContext, ProviderFetchResult, ProviderId, RateWindow, SourceMode, - TokenAccountStore, TokenAccountSupport, UsagePace, UsageSnapshot, instantiate_provider, + CostSnapshot, FetchContext, ProviderFetchResult, ProviderId, ProviderInventoryItem, RateWindow, + SourceMode, TokenAccountStore, TokenAccountSupport, UsagePace, UsageSnapshot, + instantiate_provider, }; use crate::settings::ApiKeys; use crate::status::{ProviderStatus as StatusInfo, StatusLevel, fetch_provider_status}; @@ -396,6 +398,23 @@ fn render_json_result( }); } + if !result.inventory.is_empty() { + json_result["inventory"] = serde_json::Value::Array( + result + .inventory + .iter() + .map(|item| { + serde_json::json!({ + "id": &item.id, + "title": &item.title, + "availableCount": item.available_count, + "nextExpiresAt": item.next_expires_at.map(|date| date.to_rfc3339()), + }) + }) + .collect(), + ); + } + if let Some(s) = status { json_result["status"] = serde_json::json!({ "level": format!("{:?}", s.level).to_lowercase(), @@ -460,6 +479,7 @@ pub fn render_text_with_status( append_status_line(&mut lines, status); append_account_lines(&mut lines, &result.usage); append_usage_window_lines(&mut lines, &result.usage, &metadata, use_color); + append_inventory_lines(&mut lines, &result.inventory); append_cost_line(&mut lines, result.cost.as_ref()); lines.join("\n") @@ -579,6 +599,38 @@ fn append_usage_window_lines( } } +fn append_inventory_lines(lines: &mut Vec, inventory: &[ProviderInventoryItem]) { + if inventory.is_empty() { + return; + } + let now = Utc::now(); + for item in inventory { + lines.push(format!( + " {}: {} available", + item.title, item.available_count + )); + if let Some(expires_at) = item.next_expires_at { + lines.push(format!( + " Next expires in {}", + format_inventory_countdown(expires_at, now) + )); + } + } +} + +fn format_inventory_countdown(expires_at: DateTime, now: DateTime) -> String { + let seconds = expires_at.signed_duration_since(now).num_seconds(); + if seconds <= 0 { + return "now".to_string(); + } + let minutes = (seconds + 59) / 60; + if minutes >= 24 * 60 { + format!("{}d {}h", minutes / (24 * 60), (minutes / 60) % 24) + } else { + format!("{}h {}m", minutes / 60, minutes % 60) + } +} + fn append_window_line(lines: &mut Vec, label: &str, window: &RateWindow, use_color: bool) { if window.is_informational { let description = window.reset_description.as_deref().unwrap_or("unavailable"); @@ -935,6 +987,50 @@ mod tests { ); } + #[test] + fn inventory_is_rendered_in_full_text_but_not_brief_text() { + let result = fetch_result(UsageSnapshot::new(RateWindow::new(10.0))).with_inventory_item( + ProviderInventoryItem { + id: "reset-credits".to_string(), + title: "Limit Reset Credits".to_string(), + available_count: 2, + next_expires_at: Some(Utc::now() + chrono::Duration::hours(3)), + }, + ); + + let full = render_text_with_status(ProviderId::Grok, &result, None, false); + let brief = render_brief_text(ProviderId::Grok, &result); + + assert!(full.contains("Limit Reset Credits: 2 available")); + assert!(full.contains("Next expires in")); + assert!(!brief.contains("Limit Reset Credits")); + } + + #[test] + fn json_inventory_is_additive_and_contains_no_redemption_token() { + let result = fetch_result(UsageSnapshot::new(RateWindow::new(10.0))).with_inventory_item( + ProviderInventoryItem { + id: "reset-credits".to_string(), + title: "Limit Reset Credits".to_string(), + available_count: 1, + next_expires_at: None, + }, + ); + + let json = render_json_result(ProviderId::Grok, result, None); + assert_eq!(json["inventory"][0]["availableCount"], 1); + assert!( + serde_json::to_string(&json) + .unwrap() + .contains("reset-credits") + ); + assert!( + !serde_json::to_string(&json) + .unwrap() + .contains("coupon-token-secret") + ); + } + #[test] fn secondary_label_override_is_shared_by_full_and_brief_renderers() { let result = fetch_result( diff --git a/rust/src/core/usage_snapshot.rs b/rust/src/core/usage_snapshot.rs index 3cfcd2ccf2..b0a1440814 100755 --- a/rust/src/core/usage_snapshot.rs +++ b/rust/src/core/usage_snapshot.rs @@ -85,6 +85,21 @@ pub struct NamedRateWindow { pub usage_known: bool, } +/// One display-only item of provider-issued discrete inventory. +/// +/// This is deliberately separate from [`RateWindow`]: inventory does not +/// represent a percentage quota and must not participate in quota arithmetic, +/// tray metric selection, pace, notifications, or auto-resume decisions. +/// Provider-specific redemption identifiers stay private to the provider +/// parser and never enter this type. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderInventoryItem { + pub id: String, + pub title: String, + pub available_count: u32, + pub next_expires_at: Option>, +} + fn named_rate_window_usage_known_default() -> bool { true } @@ -585,6 +600,13 @@ pub struct ProviderFetchResult { #[serde(skip_serializing_if = "Option::is_none")] pub wayfinder_usage: Option, + /// Transient non-quota inventory for provider-specific display. + /// + /// The field is intentionally skipped by serde: it belongs to the current + /// fetch and must not change persisted `ProviderFetchResult` JSON. + #[serde(skip)] + pub inventory: Vec, + /// Label describing the data source (e.g., "oauth", "web", "cli") pub source_label: String, @@ -613,6 +635,7 @@ impl ProviderFetchResult { usage, cost: None, wayfinder_usage: None, + inventory: Vec::new(), source_label: source_label.into(), has_successful_claude_cli_quota: false, pace_authoritative: true, @@ -652,6 +675,12 @@ impl ProviderFetchResult { self.wayfinder_usage = Some(usage); self } + + /// Attach one display-only inventory item without exposing redemption IDs. + pub fn with_inventory_item(mut self, item: ProviderInventoryItem) -> Self { + self.inventory.push(item); + self + } } #[cfg(test)] @@ -669,6 +698,27 @@ mod tests { ); } + #[test] + fn fetch_result_inventory_is_transient_and_not_serialized() { + let usage = UsageSnapshot::new(RateWindow::new(25.0)); + let expiry = DateTime::::from_timestamp(1_900_000_000, 0).unwrap(); + let result = + ProviderFetchResult::new(usage, "api").with_inventory_item(ProviderInventoryItem { + id: "reset-credits".to_string(), + title: "Limit Reset Credits".to_string(), + available_count: 2, + next_expires_at: Some(expiry), + }); + + assert_eq!(result.inventory.len(), 1); + let encoded = serde_json::to_value(&result).unwrap(); + assert!(encoded.get("inventory").is_none()); + assert!(encoded.get("reset-credits").is_none()); + + let decoded: ProviderFetchResult = serde_json::from_value(encoded).unwrap(); + assert!(decoded.inventory.is_empty()); + } + #[test] fn cost_snapshot_ignores_non_finite_values() { let cost = CostSnapshot::new(f64::NAN, "USD", "Monthly").with_limit(f64::INFINITY); From 4468703a92a7166e84ad2a44290af2ffe3d0d01b Mon Sep 17 00:00:00 2001 From: NessZerra <90105158+Finesssee@users.noreply.github.com> Date: Sat, 19 Sep 2026 22:47:04 +0700 Subject: [PATCH 2/8] Add transient provider detail carrier --- .../src-tauri/src/commands/bridge.rs | 37 ++++ .../src-tauri/src/commands/provider_detail.rs | 3 + .../src-tauri/src/commands/providers.rs | 1 + .../src-tauri/src/commands/tests.rs | 29 ++- apps/desktop-tauri/src-tauri/src/powertoys.rs | 2 + .../src-tauri/src/tray_bridge.rs | 1 + .../src-tauri/src/usage_metric.rs | 1 + .../src/components/MenuCardDetails.tsx | 37 ++++ .../providers/sections/UsageSection.tsx | 28 ++- apps/desktop-tauri/src/types/bridge.ts | 18 ++ rust/src/cli/usage.rs | 62 ++++++ rust/src/core/usage_snapshot.rs | 179 ++++++++++++++++++ 12 files changed, 394 insertions(+), 4 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index 1a86eab7b4..96f05a366a 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -189,6 +189,25 @@ pub struct ProviderInventoryItemSnapshot { pub next_expires_at: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderDisplayProgressSnapshot { + pub used: f64, + pub total: f64, +} + +/// Display-only provider detail row. It never participates in quota math or +/// core persistence and contains values validated by the provider carrier. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderDisplayDetailSnapshot { + pub id: String, + pub title: String, + pub value: String, + pub secondary_value: Option, + pub progress: Option, +} + /// A frontend-friendly snapshot of one provider's usage data. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -213,6 +232,8 @@ pub struct ProviderUsageSnapshot { pub extra_rate_windows: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub inventory: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub display_details: Vec, #[serde(default)] pub cost: Option, #[serde(default)] @@ -407,6 +428,21 @@ impl ProviderUsageSnapshot { next_expires_at: item.next_expires_at.map(|date| date.to_rfc3339()), }) .collect(), + display_details: result + .display_details() + .map(|detail| ProviderDisplayDetailSnapshot { + id: detail.id().to_string(), + title: detail.title().to_string(), + value: detail.value().to_string(), + secondary_value: detail.secondary_value().map(ToOwned::to_owned), + progress: detail + .progress() + .map(|progress| ProviderDisplayProgressSnapshot { + used: progress.used(), + total: progress.total(), + }), + }) + .collect(), cost: result.cost.as_ref().map(|c| CostSnapshotBridge { used: c.used, limit: c.limit, @@ -485,6 +521,7 @@ impl ProviderUsageSnapshot { tertiary_label: None, extra_rate_windows: Vec::new(), inventory: Vec::new(), + display_details: Vec::new(), cost: None, plan_name: None, account_email: None, diff --git a/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs b/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs index 18b1def39b..df5dd7d9c6 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs @@ -27,6 +27,7 @@ pub struct ProviderDetail { pub tertiary: Option, pub extra_rate_windows: Vec, pub inventory: Vec, + pub display_details: Vec, // Cost / pace. pub cost: Option, @@ -93,6 +94,7 @@ pub(crate) fn build_provider_detail(provider_id: &str) -> Result 0; const hasInventory = !provider.error && (provider.inventory?.length ?? 0) > 0; + const hasDisplayDetails = !provider.error && (provider.displayDetails?.length ?? 0) > 0; const hasCost = !!provider.cost && (costSummaryDisplayStyle !== "hidden" || provider.cost.alwaysVisible === true); @@ -470,6 +473,7 @@ export function describeCard( !provider.error && (hasMetrics || hasInventory || + hasDisplayDetails || hasCost || hasPace || hasCharts || @@ -478,6 +482,7 @@ export function describeCard( return { hasMetrics, hasInventory, + hasDisplayDetails, hasCost, hasPace, hasCharts, @@ -516,6 +521,7 @@ export default function MenuCardDetails({ const { hasMetrics, hasInventory, + hasDisplayDetails, hasCost, hasPace, hasCharts, @@ -563,6 +569,14 @@ export default function MenuCardDetails({ )} + {!provider.error && hasDisplayDetails && ( +
+ {provider.displayDetails?.map((detail, index) => ( + + ))} +
+ )} + {wayfinderUsage && } {hasMetrics && hasCost &&
} @@ -761,3 +775,26 @@ function InventoryItemRow({
); } + +function DisplayDetailRow({ detail }: { detail: ProviderDisplayDetail }) { + const progress = detail.progress; + const progressPercent = progress && Number.isFinite(progress.used) && Number.isFinite(progress.total) && progress.total > 0 + ? Math.max(0, Math.min(100, (progress.used / progress.total) * 100)) + : null; + + return ( +
+
+ {detail.title}: {detail.value} + {detail.secondaryValue && ( + {detail.secondaryValue} + )} +
+ {progressPercent != null && ( +
+
+
+ )} +
+ ); +} diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx index 64a3fa7b6a..84323eab68 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx @@ -1,4 +1,5 @@ import type { + ProviderDisplayDetail, ProviderInventoryItem, ProviderDetail, RateWindowSnapshot, @@ -62,7 +63,8 @@ export function UsageSection({ provider, resetTimeRelative, t }: Props) { } const inventory = provider.inventory ?? []; - if (bars.length === 0 && inventory.length === 0) { + const displayDetails = provider.displayDetails ?? []; + if (bars.length === 0 && inventory.length === 0 && displayDetails.length === 0) { return null; } @@ -85,6 +87,9 @@ export function UsageSection({ provider, resetTimeRelative, t }: Props) { resetTimeRelative={resetTimeRelative} /> ))} + {displayDetails.map((detail, index) => ( + + ))} ); } @@ -111,6 +116,27 @@ function InventoryRow({ ); } +function DisplayDetailRow({ detail }: { detail: ProviderDisplayDetail }) { + const progress = detail.progress; + const progressPercent = progress && Number.isFinite(progress.used) && Number.isFinite(progress.total) && progress.total > 0 + ? Math.max(0, Math.min(100, (progress.used / progress.total) * 100)) + : null; + + return ( +
+
+ {detail.title}: {detail.value} + {detail.secondaryValue && {detail.secondaryValue}} +
+ {progressPercent != null && ( +
+
+
+ )} +
+ ); +} + function UsageBar({ label, rate, diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index 963779ae2f..7fd4af94d1 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -599,6 +599,20 @@ export interface ProviderInventoryItem { nextExpiresAt: string | null; } +export interface ProviderDisplayProgress { + used: number; + total: number; +} + +/** Transient provider detail row; it is display-only and never quota math. */ +export interface ProviderDisplayDetail { + id: string; + title: string; + value: string; + secondaryValue: string | null; + progress: ProviderDisplayProgress | null; +} + /** Backend-classified provider availability state (camelCase serde on the bridge). */ export type ProviderStateKind = | "ready" @@ -627,6 +641,8 @@ export interface ProviderUsageSnapshot { }>; /** Display-only discrete provider inventory; never used as quota math. */ inventory?: ProviderInventoryItem[]; + /** Provider-specific display rows; never used as quota math or persistence. */ + displayDetails?: ProviderDisplayDetail[]; cost: CostSnapshotBridge | null; planName: string | null; accountEmail: string | null; @@ -903,6 +919,8 @@ export interface ProviderDetail { }>; /** Display-only discrete provider inventory; never used as quota math. */ inventory?: ProviderInventoryItem[]; + /** Provider-specific display rows; never used as quota math or persistence. */ + displayDetails?: ProviderDisplayDetail[]; cost: CostSnapshotBridge | null; pace: PaceSnapshot | null; diff --git a/rust/src/cli/usage.rs b/rust/src/cli/usage.rs index 25b988e5c0..9697b883c7 100755 --- a/rust/src/cli/usage.rs +++ b/rust/src/cli/usage.rs @@ -415,6 +415,28 @@ fn render_json_result( ); } + if result.display_details().next().is_some() { + json_result["details"] = serde_json::Value::Array( + result + .display_details() + .map(|detail| { + serde_json::json!({ + "id": detail.id(), + "title": detail.title(), + "value": detail.value(), + "secondaryValue": detail.secondary_value(), + "progress": detail.progress().map(|progress| { + serde_json::json!({ + "used": progress.used(), + "total": progress.total(), + }) + }), + }) + }) + .collect(), + ); + } + if let Some(s) = status { json_result["status"] = serde_json::json!({ "level": format!("{:?}", s.level).to_lowercase(), @@ -480,6 +502,7 @@ pub fn render_text_with_status( append_account_lines(&mut lines, &result.usage); append_usage_window_lines(&mut lines, &result.usage, &metadata, use_color); append_inventory_lines(&mut lines, &result.inventory); + append_display_detail_lines(&mut lines, result.display_details()); append_cost_line(&mut lines, result.cost.as_ref()); lines.join("\n") @@ -618,6 +641,29 @@ fn append_inventory_lines(lines: &mut Vec, inventory: &[ProviderInventor } } +fn append_display_detail_lines<'a>( + lines: &mut Vec, + details: impl IntoIterator, +) { + for detail in details { + let secondary = detail + .secondary_value() + .map(|value| format!(" ({value})")) + .unwrap_or_default(); + let progress = detail + .progress() + .map(|value| format!(" [{:.2}/{:.2}]", value.used(), value.total())) + .unwrap_or_default(); + lines.push(format!( + " {}: {}{}{}", + detail.title(), + detail.value(), + secondary, + progress + )); + } +} + fn format_inventory_countdown(expires_at: DateTime, now: DateTime) -> String { let seconds = expires_at.signed_duration_since(now).num_seconds(); if seconds <= 0 { @@ -1031,6 +1077,22 @@ mod tests { ); } + #[test] + fn display_details_are_rendered_in_full_text_and_json() { + let result = fetch_result(UsageSnapshot::new(RateWindow::new(10.0))).with_display_detail( + crate::core::ProviderDisplayDetail::new("credits", "Used this cycle", "12") + .with_secondary_value("Monthly refill: 100") + .with_progress(12.0, 100.0), + ); + + let full = render_text_with_status(ProviderId::Grok, &result, None, false); + let json = render_json_result(ProviderId::Grok, result, None); + + assert!(full.contains("Used this cycle: 12 (Monthly refill: 100) [12.00/100.00]")); + assert_eq!(json["details"][0]["title"], "Used this cycle"); + assert_eq!(json["details"][0]["progress"]["total"], 100.0); + } + #[test] fn secondary_label_override_is_shared_by_full_and_brief_renderers() { let result = fetch_result( diff --git a/rust/src/core/usage_snapshot.rs b/rust/src/core/usage_snapshot.rs index b0a1440814..86df6c0caa 100755 --- a/rust/src/core/usage_snapshot.rs +++ b/rust/src/core/usage_snapshot.rs @@ -100,6 +100,121 @@ pub struct ProviderInventoryItem { pub next_expires_at: Option>, } +/// One transient provider detail row for display surfaces. +/// +/// These rows are intentionally separate from quota windows and inventory: +/// providers may report credit balances, subscription metadata, or other +/// values that must be shown without becoming quota math or persisted core +/// fetch state. The builder validates compact, display-safe values before a +/// row enters a fetch result; the desktop bridge may then export those rows +/// as part of its current display snapshot. +#[derive(Debug, Clone, PartialEq)] +pub struct ProviderDisplayDetail { + id: String, + title: String, + value: String, + secondary_value: Option, + progress: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ProviderDisplayProgress { + used: f64, + total: f64, +} + +impl ProviderDisplayDetail { + pub fn new(id: impl Into, title: impl Into, value: impl Into) -> Self { + Self { + id: id.into(), + title: title.into(), + value: value.into(), + secondary_value: None, + progress: None, + } + } + + pub fn with_secondary_value(mut self, value: impl Into) -> Self { + self.secondary_value = Some(value.into()); + self + } + + pub fn with_progress(mut self, used: f64, total: f64) -> Self { + if used.is_finite() && total.is_finite() && used >= 0.0 && total > 0.0 { + self.progress = Some(ProviderDisplayProgress { used, total }); + } + self + } + + pub fn id(&self) -> &str { + &self.id + } + + pub fn title(&self) -> &str { + &self.title + } + + pub fn value(&self) -> &str { + &self.value + } + + pub fn secondary_value(&self) -> Option<&str> { + self.secondary_value.as_deref() + } + + pub fn progress(&self) -> Option { + self.progress + } + + fn is_display_safe(&self) -> bool { + is_display_safe_text(&self.id, 64) + && is_display_safe_text(&self.title, 128) + && is_display_safe_text(&self.value, 512) + && self + .secondary_value + .as_deref() + .is_none_or(|value| is_display_safe_text(value, 512)) + && self.progress.is_none_or(|progress| { + progress.used.is_finite() + && progress.total.is_finite() + && progress.used >= 0.0 + && progress.total > 0.0 + }) + } +} + +impl ProviderDisplayProgress { + pub fn used(&self) -> f64 { + self.used + } + + pub fn total(&self) -> f64 { + self.total + } +} + +fn is_display_safe_text(value: &str, max_len: usize) -> bool { + if value.is_empty() || value.chars().count() > max_len || value.chars().any(char::is_control) { + return false; + } + + let lower = value.to_ascii_lowercase(); + [ + "authorization:", + "bearer ", + "cookie:", + "set-cookie:", + "access_token", + "api_key", + "api-key", + "client_secret", + "refresh_token", + "x-api-key", + ] + .iter() + .all(|marker| !lower.contains(marker)) +} + fn named_rate_window_usage_known_default() -> bool { true } @@ -607,6 +722,12 @@ pub struct ProviderFetchResult { #[serde(skip)] pub inventory: Vec, + /// Transient provider-specific detail rows for display only. They are not + /// serialized by the core result; use [`Self::display_details`] for an + /// explicit surface projection. + #[serde(skip)] + pub display_details: Vec, + /// Label describing the data source (e.g., "oauth", "web", "cli") pub source_label: String, @@ -636,6 +757,7 @@ impl ProviderFetchResult { cost: None, wayfinder_usage: None, inventory: Vec::new(), + display_details: Vec::new(), source_label: source_label.into(), has_successful_claude_cli_quota: false, pace_authoritative: true, @@ -681,6 +803,20 @@ impl ProviderFetchResult { self.inventory.push(item); self } + + /// Attach one transient provider-specific detail row without persisting it. + pub fn with_display_detail(mut self, detail: ProviderDisplayDetail) -> Self { + if detail.is_display_safe() && !self.display_details.iter().any(|row| row.id == detail.id) { + self.display_details.push(detail); + } + self + } + + pub fn display_details(&self) -> impl Iterator { + self.display_details + .iter() + .filter(|detail| detail.is_display_safe()) + } } #[cfg(test)] @@ -719,6 +855,49 @@ mod tests { assert!(decoded.inventory.is_empty()); } + #[test] + fn fetch_result_display_details_are_transient_and_validate_progress() { + let usage = UsageSnapshot::new(RateWindow::new(25.0)); + let result = ProviderFetchResult::new(usage, "web").with_display_detail( + ProviderDisplayDetail::new("credits", "Used this cycle", "12") + .with_secondary_value("Monthly refill: 100") + .with_progress(12.0, 100.0), + ); + + let details: Vec<_> = result.display_details().collect(); + assert_eq!(details.len(), 1); + assert!(details[0].progress().is_some()); + assert!( + ProviderDisplayDetail::new("invalid", "Invalid", "value") + .with_progress(f64::NAN, 1.0) + .progress + .is_none() + ); + let encoded = serde_json::to_value(&result).unwrap(); + assert!(encoded.get("display_details").is_none()); + } + + #[test] + fn display_details_reject_secret_markers_and_duplicate_ids() { + let usage = UsageSnapshot::new(RateWindow::new(25.0)); + let result = ProviderFetchResult::new(usage, "web") + .with_display_detail(ProviderDisplayDetail::new("credits", "Credits", "12")) + .with_display_detail(ProviderDisplayDetail::new( + "credits", + "Credits duplicate", + "13", + )) + .with_display_detail(ProviderDisplayDetail::new( + "secret", + "Authorization", + "Bearer hidden", + )); + + let details: Vec<_> = result.display_details().collect(); + assert_eq!(details.len(), 1); + assert_eq!(details[0].value(), "12"); + } + #[test] fn cost_snapshot_ignores_non_finite_values() { let cost = CostSnapshot::new(f64::NAN, "USD", "Monthly").with_limit(f64::INFINITY); From 1ba2ed47fb7822c29d274b1890ec4bfa75522615 Mon Sep 17 00:00:00 2001 From: NessZerra <90105158+Finesssee@users.noreply.github.com> Date: Sat, 19 Sep 2026 23:40:20 +0700 Subject: [PATCH 3/8] Port CodeRabbit CLI usage --- .../src-tauri/src/commands/tests.rs | 29 + .../icons/ProviderIcon-coderabbit.svg | 3 + .../src/components/providers/providerIcons.ts | 3 + .../desktop-tauri/src/test/providerCatalog.ts | 1 + rust/src/cli/serve/dashboard/icons.rs | 4 + .../icons/ProviderIcon-coderabbit.svg | 3 + rust/src/core/provider.rs | 17 +- rust/src/core/provider_factory.rs | 25 +- rust/src/core/token_accounts.rs | 1 + rust/src/providers/coderabbit/mod.rs | 556 ++++++++++++++++++ rust/src/providers/mod.rs | 2 + 11 files changed, 631 insertions(+), 13 deletions(-) create mode 100644 apps/desktop-tauri/src/components/providers/icons/ProviderIcon-coderabbit.svg create mode 100644 rust/src/cli/serve/dashboard/icons/ProviderIcon-coderabbit.svg create mode 100644 rust/src/providers/coderabbit/mod.rs diff --git a/apps/desktop-tauri/src-tauri/src/commands/tests.rs b/apps/desktop-tauri/src-tauri/src/commands/tests.rs index a81fd4e41f..8dd809a408 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/tests.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/tests.rs @@ -1020,6 +1020,35 @@ fn provider_inventory_maps_to_the_bridge_without_token_ids() { assert!(!serialized.contains("coupon-token-secret")); } +#[test] +fn coderabbit_display_details_map_to_the_bridge_without_identity() { + let result = ProviderFetchResult::new( + codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::informational( + "CodeRabbit CLI", + )), + "cli", + ) + .with_display_detail(ProviderDisplayDetail::new("reviews", "Reviews", "42")) + .with_display_detail(ProviderDisplayDetail::new( + "usage-billing", + "Usage billing", + "Included", + )); + let metadata = instantiate_provider(ProviderId::CodeRabbit) + .metadata() + .clone(); + let snapshot = + ProviderUsageSnapshot::from_fetch_result(ProviderId::CodeRabbit, &metadata, &result, None); + + assert_eq!(snapshot.display_details.len(), 2); + assert_eq!(snapshot.display_details[0].id, "reviews"); + assert_eq!(snapshot.display_details[0].value, "42"); + assert_eq!(snapshot.display_details[1].title, "Usage billing"); + assert!(snapshot.account_email.is_none()); + assert!(snapshot.account_organization.is_none()); + assert!(snapshot.plan_name.is_none()); +} + #[test] fn provider_cache_is_fresh_inside_stale_window() { assert!(super::is_provider_cache_fresh( diff --git a/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-coderabbit.svg b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-coderabbit.svg new file mode 100644 index 0000000000..da00467579 --- /dev/null +++ b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-coderabbit.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/desktop-tauri/src/components/providers/providerIcons.ts b/apps/desktop-tauri/src/components/providers/providerIcons.ts index 0b554eba9b..f966d21bba 100644 --- a/apps/desktop-tauri/src/components/providers/providerIcons.ts +++ b/apps/desktop-tauri/src/components/providers/providerIcons.ts @@ -10,6 +10,7 @@ import augment from "./icons/ProviderIcon-augment.svg?raw"; import bedrock from "./icons/ProviderIcon-bedrock.svg?raw"; import claude from "./icons/ProviderIcon-claude.svg?raw"; import codebuff from "./icons/ProviderIcon-codebuff.svg?raw"; +import coderabbit from "./icons/ProviderIcon-coderabbit.svg?raw"; import codex from "./icons/ProviderIcon-codex.svg?raw"; import commandcode from "./icons/ProviderIcon-commandcode.svg?raw"; import copilot from "./icons/ProviderIcon-copilot.svg?raw"; @@ -93,6 +94,7 @@ const RAW: Record = { bedrock: tint(bedrock), claude: tint(claude), codebuff: tint(codebuff), + coderabbit: tint(coderabbit), codex: tint(codex), commandcode: tint(commandcode), copilot: tint(copilot), @@ -157,6 +159,7 @@ export const PROVIDER_ICON_REGISTRY: Record = { augment: { id: "augment", brandColor: "#6366f1", fallbackLetter: "A", svgPath: RAW.augment }, claude: { id: "claude", brandColor: "#cc7c5e", fallbackLetter: "◈", svgPath: RAW.claude }, codebuff: { id: "codebuff", brandColor: "#44ff00", fallbackLetter: "B", svgPath: RAW.codebuff }, + coderabbit: { id: "coderabbit", brandColor: "#ff5c35", fallbackLetter: "C", svgPath: RAW.coderabbit }, codex: { id: "codex", brandColor: "#49a3b0", fallbackLetter: "◆", svgPath: RAW.codex }, copilot: { id: "copilot", brandColor: "#a855f7", fallbackLetter: "⬡", svgPath: RAW.copilot }, cursor: { id: "cursor", brandColor: "#00bfa5", fallbackLetter: "▸", svgPath: RAW.cursor }, diff --git a/apps/desktop-tauri/src/test/providerCatalog.ts b/apps/desktop-tauri/src/test/providerCatalog.ts index 3281d8c070..c39676baaa 100644 --- a/apps/desktop-tauri/src/test/providerCatalog.ts +++ b/apps/desktop-tauri/src/test/providerCatalog.ts @@ -32,6 +32,7 @@ export const TEST_PROVIDER_CATALOG: Array<[string, string]> = [ ["kilo", "Kilo"], ["bedrock", "AWS Bedrock"], ["codebuff", "Codebuff"], + ["coderabbit", "CodeRabbit"], ["deepseek", "DeepSeek"], ["deepinfra", "DeepInfra"], ["fireworks", "Fireworks"], diff --git a/rust/src/cli/serve/dashboard/icons.rs b/rust/src/cli/serve/dashboard/icons.rs index 36fc7c3326..34acb6c87f 100644 --- a/rust/src/cli/serve/dashboard/icons.rs +++ b/rust/src/cli/serve/dashboard/icons.rs @@ -105,6 +105,10 @@ static ICONS: &[(&str, &[u8])] = &[ "ProviderIcon-codebuff", include_bytes!("icons/ProviderIcon-codebuff.svg"), ), + ( + "ProviderIcon-coderabbit", + include_bytes!("icons/ProviderIcon-coderabbit.svg"), + ), ( "ProviderIcon-codex", include_bytes!("icons/ProviderIcon-codex.svg"), diff --git a/rust/src/cli/serve/dashboard/icons/ProviderIcon-coderabbit.svg b/rust/src/cli/serve/dashboard/icons/ProviderIcon-coderabbit.svg new file mode 100644 index 0000000000..da00467579 --- /dev/null +++ b/rust/src/cli/serve/dashboard/icons/ProviderIcon-coderabbit.svg @@ -0,0 +1,3 @@ + + + diff --git a/rust/src/core/provider.rs b/rust/src/core/provider.rs index 55ab70d391..bd455dbf7d 100755 --- a/rust/src/core/provider.rs +++ b/rust/src/core/provider.rs @@ -45,6 +45,7 @@ pub enum ProviderId { Kilo, Bedrock, Codebuff, + CodeRabbit, DeepSeek, DeepInfra, AiAnd, @@ -123,6 +124,7 @@ impl ProviderId { ProviderId::Kilo, ProviderId::Bedrock, ProviderId::Codebuff, + ProviderId::CodeRabbit, ProviderId::DeepSeek, ProviderId::DeepInfra, ProviderId::AiAnd, @@ -200,6 +202,7 @@ impl ProviderId { ProviderId::Kilo => "kilo", ProviderId::Bedrock => "bedrock", ProviderId::Codebuff => "codebuff", + ProviderId::CodeRabbit => "coderabbit", ProviderId::DeepSeek => "deepseek", ProviderId::DeepInfra => "deepinfra", ProviderId::Fireworks => "fireworks", @@ -278,6 +281,7 @@ impl ProviderId { ProviderId::Kilo => "Kilo", ProviderId::Bedrock => "AWS Bedrock", ProviderId::Codebuff => "Codebuff", + ProviderId::CodeRabbit => "CodeRabbit", ProviderId::DeepSeek => "DeepSeek", ProviderId::DeepInfra => "DeepInfra", ProviderId::Fireworks => "Fireworks", @@ -369,6 +373,7 @@ impl ProviderId { ProviderId::Kilo => None, ProviderId::Bedrock => None, ProviderId::Codebuff => None, + ProviderId::CodeRabbit => None, ProviderId::DeepSeek => None, ProviderId::DeepInfra => None, ProviderId::Fireworks => None, @@ -443,6 +448,7 @@ impl ProviderId { "kilo" => Some(ProviderId::Kilo), "bedrock" | "aws-bedrock" | "aws bedrock" => Some(ProviderId::Bedrock), "codebuff" | "manicode" => Some(ProviderId::Codebuff), + "coderabbit" | "code-rabbit" | "code rabbit" => Some(ProviderId::CodeRabbit), "deepseek" | "deep-seek" | "ds" => Some(ProviderId::DeepSeek), "deepinfra" | "deep-infra" | "di" => Some(ProviderId::DeepInfra), "fireworks" | "fireworks-ai" | "fw" => Some(ProviderId::Fireworks), @@ -938,6 +944,7 @@ pub fn brand_color(id: ProviderId) -> &'static str { ProviderId::Kilo => "#5D87FF", ProviderId::Bedrock => "#FF9900", ProviderId::Codebuff => "#44FF00", + ProviderId::CodeRabbit => "#FF5C35", ProviderId::DeepSeek => "#527DF0", ProviderId::DeepInfra => "#2A3275", ProviderId::AiAnd => "#E25C2B", @@ -990,7 +997,7 @@ mod tests { #[test] fn test_provider_id_all() { let all = ProviderId::all(); - assert_eq!(all.len(), 71); + assert_eq!(all.len(), 72); assert!(all.contains(&ProviderId::Claude)); assert!(all.contains(&ProviderId::Codex)); assert!(all.contains(&ProviderId::Fireworks)); @@ -1005,6 +1012,7 @@ mod tests { assert!(all.contains(&ProviderId::Infini)); assert!(all.contains(&ProviderId::Bedrock)); assert!(all.contains(&ProviderId::Codebuff)); + assert!(all.contains(&ProviderId::CodeRabbit)); assert!(all.contains(&ProviderId::DeepSeek)); assert!(all.contains(&ProviderId::DeepInfra)); assert!(all.contains(&ProviderId::AiAnd)); @@ -1077,6 +1085,7 @@ mod tests { assert_eq!(ProviderId::Codex.cli_name(), "codex"); assert_eq!(ProviderId::Factory.cli_name(), "factory"); assert_eq!(ProviderId::Zai.cli_name(), "zai"); + assert_eq!(ProviderId::CodeRabbit.cli_name(), "coderabbit"); } #[test] @@ -1084,6 +1093,7 @@ mod tests { assert_eq!(ProviderId::Claude.display_name(), "Claude"); assert_eq!(ProviderId::Factory.display_name(), "Factory"); assert_eq!(ProviderId::Zai.display_name(), "z.ai"); + assert_eq!(ProviderId::CodeRabbit.display_name(), "CodeRabbit"); } #[test] @@ -1112,6 +1122,10 @@ mod tests { ); assert_eq!(ProviderId::from_cli_name("zed"), Some(ProviderId::Zed)); assert_eq!(ProviderId::from_cli_name("unknown"), None); + assert_eq!( + ProviderId::from_cli_name("code-rabbit"), + Some(ProviderId::CodeRabbit) + ); } #[test] @@ -1183,6 +1197,7 @@ mod tests { assert_eq!(ProviderId::Zai.cookie_domain(), None); assert_eq!(ProviderId::VertexAI.cookie_domain(), None); assert_eq!(ProviderId::JetBrains.cookie_domain(), None); + assert_eq!(ProviderId::CodeRabbit.cookie_domain(), None); } #[test] diff --git a/rust/src/core/provider_factory.rs b/rust/src/core/provider_factory.rs index 093b5cf089..f32fdea88a 100644 --- a/rust/src/core/provider_factory.rs +++ b/rust/src/core/provider_factory.rs @@ -9,18 +9,18 @@ use super::{Provider, ProviderId}; use crate::providers::{ AbacusProvider, AiAndProvider, AlibabaProvider, AlibabaTokenPlanProvider, AmpProvider, AntigravityProvider, AugmentProvider, AzureOpenAIProvider, BedrockProvider, ChutesProvider, - ClaudeProvider, ClinePassProvider, CodeBuddyProvider, CodebuffProvider, CodexProvider, - CommandCodeProvider, CopilotProvider, CrofProvider, CrossModelProvider, CursorProvider, - DeepInfraProvider, DeepSeekProvider, DeepgramProvider, DevinProvider, DoubaoProvider, - ElevenLabsProvider, FactoryProvider, FireworksProvider, GeminiProvider, GrokProvider, - GroqProvider, InfiniProvider, JetBrainsProvider, KiloProvider, KimiK2Provider, KimiProvider, - KiroProvider, LLMProxyProvider, LiteLLMProvider, LongCatProvider, ManusProvider, MetaProvider, - MiMoProvider, MiniMaxProvider, MistralProvider, NanoGPTProvider, NeuralwattProvider, - NotionProvider, OllamaProvider, OpenAIApiProvider, OpenCodeGoProvider, OpenCodeProvider, - OpenRouterProvider, PerplexityProvider, PoeProvider, QoderProvider, QwenCloudProvider, - SakanaProvider, StepFunProvider, Sub2ApiProvider, T3ChatProvider, VeniceProvider, - VertexAIProvider, WarpProvider, WayfinderProvider, WindsurfProvider, XaiProvider, ZaiProvider, - ZedProvider, ZenMuxProvider, ZoomMateProvider, + ClaudeProvider, ClinePassProvider, CodeBuddyProvider, CodeRabbitProvider, CodebuffProvider, + CodexProvider, CommandCodeProvider, CopilotProvider, CrofProvider, CrossModelProvider, + CursorProvider, DeepInfraProvider, DeepSeekProvider, DeepgramProvider, DevinProvider, + DoubaoProvider, ElevenLabsProvider, FactoryProvider, FireworksProvider, GeminiProvider, + GrokProvider, GroqProvider, InfiniProvider, JetBrainsProvider, KiloProvider, KimiK2Provider, + KimiProvider, KiroProvider, LLMProxyProvider, LiteLLMProvider, LongCatProvider, ManusProvider, + MetaProvider, MiMoProvider, MiniMaxProvider, MistralProvider, NanoGPTProvider, + NeuralwattProvider, NotionProvider, OllamaProvider, OpenAIApiProvider, OpenCodeGoProvider, + OpenCodeProvider, OpenRouterProvider, PerplexityProvider, PoeProvider, QoderProvider, + QwenCloudProvider, SakanaProvider, StepFunProvider, Sub2ApiProvider, T3ChatProvider, + VeniceProvider, VertexAIProvider, WarpProvider, WayfinderProvider, WindsurfProvider, + XaiProvider, ZaiProvider, ZedProvider, ZenMuxProvider, ZoomMateProvider, }; /// Instantiate the concrete [`Provider`] implementation for a given [`ProviderId`]. @@ -62,6 +62,7 @@ pub fn instantiate(id: ProviderId) -> Box { ProviderId::Kilo => Box::new(KiloProvider::new()), ProviderId::Bedrock => Box::new(BedrockProvider::new()), ProviderId::Codebuff => Box::new(CodebuffProvider::new()), + ProviderId::CodeRabbit => Box::new(CodeRabbitProvider::new()), ProviderId::DeepSeek => Box::new(DeepSeekProvider::new()), ProviderId::DeepInfra => Box::new(DeepInfraProvider::new()), ProviderId::AiAnd => Box::new(AiAndProvider::new()), diff --git a/rust/src/core/token_accounts.rs b/rust/src/core/token_accounts.rs index a8ae172b17..dfeba4af21 100755 --- a/rust/src/core/token_accounts.rs +++ b/rust/src/core/token_accounts.rs @@ -331,6 +331,7 @@ impl TokenAccountSupport { | ProviderId::Kilo | ProviderId::Bedrock | ProviderId::Codebuff + | ProviderId::CodeRabbit | ProviderId::DeepSeek | ProviderId::Windsurf | ProviderId::Doubao diff --git a/rust/src/providers/coderabbit/mod.rs b/rust/src/providers/coderabbit/mod.rs new file mode 100644 index 0000000000..95c7996e79 --- /dev/null +++ b/rust/src/providers/coderabbit/mod.rs @@ -0,0 +1,556 @@ +//! CodeRabbit provider implementation. +//! +//! CodeRabbit exposes its usage through the local `coderabbit usage` command. +//! This provider deliberately keeps that boundary local: it does not inspect +//! browser state, make network requests, or persist the command output. + +use async_trait::async_trait; +use std::process::Stdio; +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; +use std::time::Duration; +use tokio::io::{AsyncRead, AsyncReadExt}; +use tokio::process::Command; +use tokio::sync::mpsc; + +use crate::core::{ + FetchContext, Provider, ProviderDisplayDetail, ProviderError, ProviderFetchResult, ProviderId, + ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, +}; + +const CLI_TIMEOUT: Duration = Duration::from_secs(15); +const MAX_OUTPUT_BYTES: usize = 128 * 1024; +const MAX_FIELD_CHARS: usize = 512; +const DEFAULT_PROGRAM: &str = "coderabbit"; +const PROGRAM_OVERRIDE_ENV: &str = "CODERABBIT_CLI_PATH"; + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +struct CodeRabbitUsage { + organization: Option, + user: Option, + plan: Option, + reviews: Option, + usage_billing: Option, + period_resets: Option, +} + +#[derive(Debug)] +struct CliOutput { + status: std::process::ExitStatus, + stdout: Vec, + stderr: Vec, +} + +#[derive(Debug, Clone, Copy)] +enum StreamKind { + Stdout, + Stderr, +} + +pub struct CodeRabbitProvider { + metadata: ProviderMetadata, +} + +impl CodeRabbitProvider { + pub fn new() -> Self { + Self { + metadata: ProviderMetadata { + id: ProviderId::CodeRabbit, + display_name: "CodeRabbit", + session_label: "Reviews", + weekly_label: "Billing", + supports_opus: false, + supports_credits: false, + default_enabled: false, + is_primary: false, + dashboard_url: Some("https://app.coderabbit.ai"), + status_page_url: Some("https://status.coderabbit.ai"), + }, + } + } +} + +impl Default for CodeRabbitProvider { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Provider for CodeRabbitProvider { + fn id(&self) -> ProviderId { + ProviderId::CodeRabbit + } + + fn metadata(&self) -> &ProviderMetadata { + &self.metadata + } + + async fn fetch_usage(&self, ctx: &FetchContext) -> Result { + match ctx.source_mode { + SourceMode::Auto | SourceMode::Cli => { + let output = run_cli(&configured_program()).await?; + let mut combined = output.stdout; + combined.push(b'\n'); + combined.extend_from_slice(&output.stderr); + let combined = String::from_utf8_lossy(&combined); + + if !output.status.success() { + if looks_signed_out(&combined) { + return Err(ProviderError::AuthRequired); + } + return Err(ProviderError::Other(format!( + "CodeRabbit CLI failed with exit code {}", + output + .status + .code() + .map_or_else(|| "unknown".to_string(), |code| code.to_string()) + ))); + } + + let usage = parse_usage(&combined)?; + Ok(fetch_result(&usage)) + } + SourceMode::OAuth | SourceMode::Web => { + Err(ProviderError::UnsupportedSource(ctx.source_mode)) + } + } + } + + fn available_sources(&self) -> Vec { + vec![SourceMode::Auto, SourceMode::Cli] + } + + fn supports_cli(&self) -> bool { + true + } +} + +fn configured_program() -> String { + if let Some(override_value) = std::env::var(PROGRAM_OVERRIDE_ENV) + .ok() + .filter(|value| !value.trim().is_empty()) + { + return program_from_override(Some(&override_value)); + } + which::which(DEFAULT_PROGRAM) + .ok() + .map(|path| path.to_string_lossy().into_owned()) + .unwrap_or_else(|| DEFAULT_PROGRAM.to_string()) +} + +fn program_from_override(override_value: Option<&str>) -> String { + override_value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| DEFAULT_PROGRAM.to_string()) +} + +async fn run_cli(program: &str) -> Result { + let mut command = Command::new(program); + command + .arg("usage") + .env("NO_COLOR", "1") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + #[cfg(windows)] + command.creation_flags(0x0800_0000); + let mut child = command.spawn().map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + ProviderError::NotInstalled( + "CodeRabbit CLI not found. Install it or set CODERABBIT_CLI_PATH.".to_string(), + ) + } else { + ProviderError::Other("CodeRabbit CLI could not be started.".to_string()) + } + })?; + + let stdout = child.stdout.take().ok_or_else(|| { + ProviderError::Other("CodeRabbit CLI stdout was unavailable.".to_string()) + })?; + let stderr = child.stderr.take().ok_or_else(|| { + ProviderError::Other("CodeRabbit CLI stderr was unavailable.".to_string()) + })?; + + let budget = Arc::new(AtomicUsize::new(0)); + let (sender, mut receiver) = mpsc::channel(2); + let stdout_task = tokio::spawn(read_stream( + StreamKind::Stdout, + stdout, + Arc::clone(&budget), + sender.clone(), + )); + let stderr_task = tokio::spawn(read_stream(StreamKind::Stderr, stderr, budget, sender)); + + let mut wait = Box::pin(child.wait()); + let mut status = None; + let mut stdout_bytes = None; + let mut stderr_bytes = None; + + let outcome = tokio::time::timeout(CLI_TIMEOUT, async { + while status.is_none() || stdout_bytes.is_none() || stderr_bytes.is_none() { + tokio::select! { + exit = &mut wait, if status.is_none() => { + status = Some(exit.map_err(|_| ProviderError::Other( + "CodeRabbit CLI process wait failed.".to_string(), + ))?); + } + message = receiver.recv() => { + let Some((kind, bytes)) = message else { + return Err(ProviderError::Other( + "CodeRabbit CLI output streams closed unexpectedly.".to_string(), + )); + }; + let bytes = bytes.map_err(ProviderError::Other)?; + match kind { + StreamKind::Stdout => stdout_bytes = Some(bytes), + StreamKind::Stderr => stderr_bytes = Some(bytes), + } + } + } + } + + Ok(CliOutput { + status: status.expect("process status collected"), + stdout: stdout_bytes.expect("stdout collected"), + stderr: stderr_bytes.expect("stderr collected"), + }) + }) + .await; + + drop(wait); + let needs_cleanup = outcome.is_err() || matches!(outcome, Ok(Err(_))); + if needs_cleanup { + drop(child.kill().await); + drop(child.wait().await); + } + + drop(stdout_task.await); + drop(stderr_task.await); + + match outcome { + Ok(result) => result, + Err(_) => Err(ProviderError::Timeout), + } +} + +async fn read_stream( + kind: StreamKind, + mut reader: R, + budget: Arc, + sender: mpsc::Sender<(StreamKind, Result, String>)>, +) { + let result = read_bounded(&mut reader, &budget).await; + drop(sender.send((kind, result)).await); +} + +async fn read_bounded( + reader: &mut R, + budget: &AtomicUsize, +) -> Result, String> { + let mut output = Vec::new(); + let mut buffer = [0_u8; 8192]; + loop { + let count = reader + .read(&mut buffer) + .await + .map_err(|_| "CodeRabbit CLI output could not be read.".to_string())?; + if count == 0 { + return Ok(output); + } + + reserve_output_bytes(budget, count)?; + output.extend_from_slice(&buffer[..count]); + } +} + +fn reserve_output_bytes(budget: &AtomicUsize, count: usize) -> Result<(), String> { + let mut current = budget.load(Ordering::Relaxed); + loop { + let Some(next) = current.checked_add(count) else { + return Err("CodeRabbit CLI output exceeded 128 KiB.".to_string()); + }; + if next > MAX_OUTPUT_BYTES { + return Err("CodeRabbit CLI output exceeded 128 KiB.".to_string()); + } + match budget.compare_exchange_weak(current, next, Ordering::Relaxed, Ordering::Relaxed) { + Ok(_) => return Ok(()), + Err(actual) => current = actual, + } + } +} + +fn parse_usage(text: &str) -> Result { + if text.len() > MAX_OUTPUT_BYTES { + return Err(ProviderError::Parse( + "CodeRabbit CLI output exceeded 128 KiB.".to_string(), + )); + } + + let text = strip_ansi_codes(text); + let mut usage = CodeRabbitUsage::default(); + for line in text.lines() { + let Some((label, raw_value)) = line.split_once(':') else { + continue; + }; + let label = label.trim().to_ascii_lowercase(); + let value = raw_value.trim(); + if value.is_empty() { + continue; + } + if value.chars().count() > MAX_FIELD_CHARS || value.chars().any(char::is_control) { + return Err(ProviderError::Parse( + "CodeRabbit CLI returned an invalid field.".to_string(), + )); + } + + match label.as_str() { + "organization" if usage.organization.is_none() => { + usage.organization = Some(value.to_string()) + } + "user" if usage.user.is_none() => usage.user = Some(value.to_string()), + "plan" if usage.plan.is_none() => usage.plan = Some(value.to_string()), + "your reviews" if usage.reviews.is_none() => { + usage.reviews = value.parse::().ok(); + } + "usage billing" if usage.usage_billing.is_none() => { + usage.usage_billing = Some(value.to_string()) + } + "period resets" if usage.period_resets.is_none() => { + usage.period_resets = Some(value.to_string()) + } + _ => {} + } + } + + if usage.reviews.is_none() && usage.usage_billing.is_none() && usage.period_resets.is_none() { + if looks_signed_out(&text) { + return Err(ProviderError::AuthRequired); + } + return Err(ProviderError::Parse( + "CodeRabbit CLI returned no usage fields.".to_string(), + )); + } + + Ok(usage) +} + +fn fetch_result(usage: &CodeRabbitUsage) -> ProviderFetchResult { + let mut result = ProviderFetchResult::new( + UsageSnapshot::new(RateWindow::informational("CodeRabbit CLI")) + .with_primary_label("Reviews"), + "cli", + ) + .with_non_authoritative_pace(); + + if let Some(value) = usage.organization.as_deref() { + result = result.with_display_detail(ProviderDisplayDetail::new( + "organization", + "Organization", + value, + )); + } + if let Some(value) = usage.user.as_deref() { + result = result.with_display_detail(ProviderDisplayDetail::new("user", "User", value)); + } + if let Some(value) = usage.plan.as_deref() { + result = result.with_display_detail(ProviderDisplayDetail::new("plan", "Plan", value)); + } + if let Some(value) = usage.reviews { + result = result.with_display_detail(ProviderDisplayDetail::new( + "reviews", + "Reviews", + value.to_string(), + )); + } + if let Some(value) = usage.usage_billing.as_deref() { + result = result.with_display_detail(ProviderDisplayDetail::new( + "usage-billing", + "Usage billing", + value, + )); + } + if let Some(value) = usage.period_resets.as_deref() { + result = result.with_display_detail(ProviderDisplayDetail::new( + "period-resets", + "Period resets", + value, + )); + } + result +} + +fn looks_signed_out(text: &str) -> bool { + let lower = text.to_ascii_lowercase(); + [ + "not authenticated", + "please log in", + "auth login", + "authentication required", + "unauthorized", + "no session found", + ] + .iter() + .any(|marker| lower.contains(marker)) +} + +fn strip_ansi_codes(text: &str) -> String { + let mut output = String::with_capacity(text.len()); + let mut chars = text.chars().peekable(); + while let Some(ch) = chars.next() { + if ch != '\u{1b}' { + output.push(ch); + continue; + } + + match chars.peek().copied() { + Some('[') => { + chars.next(); + for control in chars.by_ref() { + if ('@'..='~').contains(&control) { + break; + } + } + } + Some(']') => { + chars.next(); + let mut previous = None; + for control in chars.by_ref() { + if control == '\u{7}' || (previous == Some('\u{1b}') && control == '\\') { + break; + } + previous = Some(control); + } + } + _ => {} + } + } + output +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::{AsyncWriteExt, duplex}; + + #[test] + fn parses_supported_fields_with_first_value_wins() { + let usage = parse_usage( + "Organization: acme\nUser: ness\nPlan: Pro\nYour reviews: 42\nYour reviews: 99\nUsage billing: Included\nPeriod resets: Friday", + ) + .expect("valid CodeRabbit output"); + + assert_eq!(usage.organization.as_deref(), Some("acme")); + assert_eq!(usage.user.as_deref(), Some("ness")); + assert_eq!(usage.plan.as_deref(), Some("Pro")); + assert_eq!(usage.reviews, Some(42)); + assert_eq!(usage.usage_billing.as_deref(), Some("Included")); + assert_eq!(usage.period_resets.as_deref(), Some("Friday")); + } + + #[test] + fn strips_ansi_and_rejects_signed_out_or_unrelated_output() { + let usage = parse_usage("\u{1b}[32mYour reviews: 3\u{1b}[0m").unwrap(); + assert_eq!(usage.reviews, Some(3)); + + assert!(matches!( + parse_usage("Please log in with auth login"), + Err(ProviderError::AuthRequired) + )); + assert!(matches!( + parse_usage("status: ready"), + Err(ProviderError::Parse(_)) + )); + } + + #[test] + fn ignores_negative_review_counts_and_requires_usage_structure() { + assert!(matches!( + parse_usage("Your reviews: -1"), + Err(ProviderError::Parse(_)) + )); + let usage = parse_usage("Period resets: tomorrow").unwrap(); + assert_eq!(usage.period_resets.as_deref(), Some("tomorrow")); + } + + #[test] + fn rejects_control_or_oversized_fields_before_display_projection() { + assert!(matches!( + parse_usage("Plan: bad\u{1b}[31m"), + Err(ProviderError::Parse(_)) + )); + let oversized = format!("Plan: {}\nYour reviews: 1", "x".repeat(MAX_FIELD_CHARS + 1)); + assert!(matches!( + parse_usage(&oversized), + Err(ProviderError::Parse(_)) + )); + } + + #[test] + fn configured_path_override_is_trimmed_without_shell_parsing() { + assert_eq!( + program_from_override(Some(" C:\\Tools\\coderabbit.exe ")), + "C:\\Tools\\coderabbit.exe" + ); + assert_eq!(program_from_override(Some(" ")), DEFAULT_PROGRAM); + assert_eq!(program_from_override(None), DEFAULT_PROGRAM); + } + + #[tokio::test] + async fn bounded_reader_rejects_combined_output_budget() { + let (mut writer, mut reader) = duplex(MAX_OUTPUT_BYTES + 1); + let writer_task = tokio::spawn(async move { + let bytes = vec![b'x'; MAX_OUTPUT_BYTES + 1]; + drop(writer.write_all(&bytes).await); + }); + let budget = AtomicUsize::new(0); + let result = read_bounded(&mut reader, &budget).await; + assert!(result.is_err()); + drop(writer_task.await); + } + + #[test] + fn result_keeps_details_transient_and_does_not_set_identity() { + let usage = parse_usage( + "Organization: acme\nUser: ness@example.test\nPlan: Pro\nYour reviews: 4\nUsage billing: Included\nPeriod resets: Friday", + ) + .unwrap(); + let result = fetch_result(&usage); + let detail_ids = result + .display_details() + .map(ProviderDisplayDetail::id) + .collect::>(); + assert_eq!( + detail_ids, + vec![ + "organization", + "user", + "plan", + "reviews", + "usage-billing", + "period-resets", + ] + ); + assert!(result.account_identity().is_none()); + assert!(result.usage.account_organization.is_none()); + assert!(result.usage.login_method.is_none()); + let encoded = serde_json::to_value(result).unwrap(); + assert!(encoded.get("display_details").is_none()); + } + + #[test] + fn provider_is_cli_only_and_disabled_by_default() { + let provider = CodeRabbitProvider::new(); + assert_eq!(provider.id(), ProviderId::CodeRabbit); + assert_eq!( + provider.available_sources(), + vec![SourceMode::Auto, SourceMode::Cli] + ); + assert!(provider.supports_cli()); + assert!(!provider.metadata().default_enabled); + } +} diff --git a/rust/src/providers/mod.rs b/rust/src/providers/mod.rs index 1e32aaecd5..6d7908c774 100755 --- a/rust/src/providers/mod.rs +++ b/rust/src/providers/mod.rs @@ -19,6 +19,7 @@ pub mod claude; pub mod clinepass; pub mod codebuddy; pub mod codebuff; +pub mod coderabbit; pub mod codex; pub mod commandcode; pub mod copilot; @@ -93,6 +94,7 @@ pub use claude::ClaudeProvider; pub use clinepass::ClinePassProvider; pub use codebuddy::CodeBuddyProvider; pub use codebuff::CodebuffProvider; +pub use coderabbit::CodeRabbitProvider; pub use codex::CodexProvider; pub use commandcode::CommandCodeProvider; pub use copilot::CopilotProvider; From f5ff1d0b3566e2713c1d0d762a850d322880d14a Mon Sep 17 00:00:00 2001 From: NessZerra <90105158+Finesssee@users.noreply.github.com> Date: Sat, 19 Sep 2026 23:55:04 +0700 Subject: [PATCH 4/8] Harden CodeRabbit output parsing --- rust/src/providers/coderabbit/mod.rs | 161 ++++++++++++++++----------- 1 file changed, 98 insertions(+), 63 deletions(-) diff --git a/rust/src/providers/coderabbit/mod.rs b/rust/src/providers/coderabbit/mod.rs index 95c7996e79..8bf78d0153 100644 --- a/rust/src/providers/coderabbit/mod.rs +++ b/rust/src/providers/coderabbit/mod.rs @@ -95,10 +95,10 @@ impl Provider for CodeRabbitProvider { let mut combined = output.stdout; combined.push(b'\n'); combined.extend_from_slice(&output.stderr); - let combined = String::from_utf8_lossy(&combined); + let combined = decode_cli_output(&combined)?; if !output.status.success() { - if looks_signed_out(&combined) { + if looks_signed_out(combined) { return Err(ProviderError::AuthRequired); } return Err(ProviderError::Other(format!( @@ -110,7 +110,7 @@ impl Provider for CodeRabbitProvider { ))); } - let usage = parse_usage(&combined)?; + let usage = parse_usage(combined)?; Ok(fetch_result(&usage)) } SourceMode::OAuth | SourceMode::Web => { @@ -238,6 +238,12 @@ async fn run_cli(program: &str) -> Result { } } +fn decode_cli_output(bytes: &[u8]) -> Result<&str, ProviderError> { + std::str::from_utf8(bytes).map_err(|_| { + ProviderError::Parse("CodeRabbit CLI returned invalid UTF-8 output.".to_string()) + }) +} + async fn read_stream( kind: StreamKind, mut reader: R, @@ -291,17 +297,31 @@ fn parse_usage(text: &str) -> Result { )); } - let text = strip_ansi_codes(text); + if text + .chars() + .any(|character| character.is_control() && !matches!(character, '\n' | '\r')) + { + return Err(ProviderError::Parse( + "CodeRabbit CLI returned an invalid field.".to_string(), + )); + } + if looks_signed_out(text) { + return Err(ProviderError::AuthRequired); + } + let mut usage = CodeRabbitUsage::default(); + let mut seen_organization = false; + let mut seen_user = false; + let mut seen_plan = false; + let mut seen_reviews = false; + let mut seen_usage_billing = false; + let mut seen_period_resets = false; for line in text.lines() { let Some((label, raw_value)) = line.split_once(':') else { continue; }; let label = label.trim().to_ascii_lowercase(); let value = raw_value.trim(); - if value.is_empty() { - continue; - } if value.chars().count() > MAX_FIELD_CHARS || value.chars().any(char::is_control) { return Err(ProviderError::Parse( "CodeRabbit CLI returned an invalid field.".to_string(), @@ -309,28 +329,49 @@ fn parse_usage(text: &str) -> Result { } match label.as_str() { - "organization" if usage.organization.is_none() => { - usage.organization = Some(value.to_string()) + "organization" if !seen_organization => { + seen_organization = true; + if !value.is_empty() { + usage.organization = Some(value.to_string()); + } + } + "user" if !seen_user => { + seen_user = true; + if !value.is_empty() { + usage.user = Some(value.to_string()); + } } - "user" if usage.user.is_none() => usage.user = Some(value.to_string()), - "plan" if usage.plan.is_none() => usage.plan = Some(value.to_string()), - "your reviews" if usage.reviews.is_none() => { - usage.reviews = value.parse::().ok(); + "plan" if !seen_plan => { + seen_plan = true; + if !value.is_empty() { + usage.plan = Some(value.to_string()); + } + } + "your reviews" if !seen_reviews => { + seen_reviews = true; + usage.reviews = Some(value.parse::().map_err(|_| { + ProviderError::Parse( + "CodeRabbit CLI returned an invalid review count.".to_string(), + ) + })?); } - "usage billing" if usage.usage_billing.is_none() => { - usage.usage_billing = Some(value.to_string()) + "usage billing" if !seen_usage_billing => { + seen_usage_billing = true; + if !value.is_empty() { + usage.usage_billing = Some(value.to_string()); + } } - "period resets" if usage.period_resets.is_none() => { - usage.period_resets = Some(value.to_string()) + "period resets" if !seen_period_resets => { + seen_period_resets = true; + if !value.is_empty() { + usage.period_resets = Some(value.to_string()); + } } _ => {} } } - if usage.reviews.is_none() && usage.usage_billing.is_none() && usage.period_resets.is_none() { - if looks_signed_out(&text) { - return Err(ProviderError::AuthRequired); - } + if usage.reviews.is_none() && usage.usage_billing.is_none() { return Err(ProviderError::Parse( "CodeRabbit CLI returned no usage fields.".to_string(), )); @@ -389,7 +430,6 @@ fn looks_signed_out(text: &str) -> bool { [ "not authenticated", "please log in", - "auth login", "authentication required", "unauthorized", "no session found", @@ -398,40 +438,6 @@ fn looks_signed_out(text: &str) -> bool { .any(|marker| lower.contains(marker)) } -fn strip_ansi_codes(text: &str) -> String { - let mut output = String::with_capacity(text.len()); - let mut chars = text.chars().peekable(); - while let Some(ch) = chars.next() { - if ch != '\u{1b}' { - output.push(ch); - continue; - } - - match chars.peek().copied() { - Some('[') => { - chars.next(); - for control in chars.by_ref() { - if ('@'..='~').contains(&control) { - break; - } - } - } - Some(']') => { - chars.next(); - let mut previous = None; - for control in chars.by_ref() { - if control == '\u{7}' || (previous == Some('\u{1b}') && control == '\\') { - break; - } - previous = Some(control); - } - } - _ => {} - } - } - output -} - #[cfg(test)] mod tests { use super::*; @@ -453,12 +459,14 @@ mod tests { } #[test] - fn strips_ansi_and_rejects_signed_out_or_unrelated_output() { - let usage = parse_usage("\u{1b}[32mYour reviews: 3\u{1b}[0m").unwrap(); - assert_eq!(usage.reviews, Some(3)); + fn rejects_control_sequences_and_signed_out_output_even_with_usage() { + assert!(matches!( + parse_usage("\u{1b}[32mYour reviews: 3\u{1b}[0m"), + Err(ProviderError::Parse(_)) + )); assert!(matches!( - parse_usage("Please log in with auth login"), + parse_usage("Your reviews: 3\nPlease log in with auth login"), Err(ProviderError::AuthRequired) )); assert!(matches!( @@ -468,13 +476,17 @@ mod tests { } #[test] - fn ignores_negative_review_counts_and_requires_usage_structure() { + fn rejects_invalid_review_counts_and_requires_usage_structure() { assert!(matches!( parse_usage("Your reviews: -1"), Err(ProviderError::Parse(_)) )); - let usage = parse_usage("Period resets: tomorrow").unwrap(); - assert_eq!(usage.period_resets.as_deref(), Some("tomorrow")); + assert!(matches!( + parse_usage("Period resets: tomorrow"), + Err(ProviderError::Parse(_)) + )); + let usage = parse_usage("Your reviews: 0").unwrap(); + assert_eq!(usage.reviews, Some(0)); } #[test] @@ -490,6 +502,29 @@ mod tests { )); } + #[test] + fn rejects_invalid_utf8_before_parsing() { + assert!(matches!( + decode_cli_output(b"Your reviews: 3\xff"), + Err(ProviderError::Parse(_)) + )); + } + + #[test] + fn malformed_first_review_value_cannot_be_replaced_by_a_later_duplicate() { + assert!(matches!( + parse_usage("Your reviews: unknown\nYour reviews: 3\nUsage billing: Included"), + Err(ProviderError::Parse(_)) + )); + } + + #[test] + fn help_text_can_mention_login_without_marking_a_valid_report_signed_out() { + let usage = + parse_usage("Your reviews: 3\nTo switch accounts, run coderabbit auth login.").unwrap(); + assert_eq!(usage.reviews, Some(3)); + } + #[test] fn configured_path_override_is_trimmed_without_shell_parsing() { assert_eq!( From 2d9559d77253042b09eba1614525bbfa73d509c5 Mon Sep 17 00:00:00 2001 From: NessZerra <90105158+Finesssee@users.noreply.github.com> Date: Mon, 21 Sep 2026 04:06:31 +0700 Subject: [PATCH 5/8] Address thermo-nuclear review: simplify run_cli and fold details --- .../src-tauri/src/commands/bridge.rs | 9 - .../src-tauri/src/commands/provider_detail.rs | 9 - .../src-tauri/src/commands/providers.rs | 3 - rust/src/cli/usage/render.rs | 46 +++ rust/src/core/usage_snapshot.rs | 15 - rust/src/providers/coderabbit/mod.rs | 356 +++++++++--------- 6 files changed, 215 insertions(+), 223 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index b79d55aabc..62d1976398 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -193,7 +193,6 @@ pub struct ProviderInventoryItemSnapshot { pub next_expires_at: Option, } -<<<<<<< HEAD #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ProviderDisplayProgressSnapshot { @@ -213,8 +212,6 @@ pub struct ProviderDisplayDetailSnapshot { pub progress: Option, } -======= ->>>>>>> origin/main /// A frontend-friendly snapshot of one provider's usage data. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -239,11 +236,8 @@ pub struct ProviderUsageSnapshot { pub extra_rate_windows: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub inventory: Vec, -<<<<<<< HEAD #[serde(default, skip_serializing_if = "Vec::is_empty")] pub display_details: Vec, -======= ->>>>>>> origin/main #[serde(default)] pub cost: Option, #[serde(default)] @@ -532,10 +526,7 @@ impl ProviderUsageSnapshot { tertiary_label: None, extra_rate_windows: Vec::new(), inventory: Vec::new(), -<<<<<<< HEAD display_details: Vec::new(), -======= ->>>>>>> origin/main cost: None, plan_name: None, account_email: None, diff --git a/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs b/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs index 472f66c772..ddce69b914 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs @@ -30,10 +30,7 @@ pub struct ProviderDetail { pub tertiary_label_key: Option<&'static str>, pub extra_rate_windows: Vec, pub inventory: Vec, -<<<<<<< HEAD pub display_details: Vec, -======= ->>>>>>> origin/main // Cost / pace. pub cost: Option, @@ -101,10 +98,7 @@ pub(crate) fn build_provider_detail(provider_id: &str) -> Result>>>>>> origin/main cost: None, pace: None, last_error: None, @@ -169,10 +163,7 @@ pub fn get_provider_detail( detail.tertiary = snapshot.tertiary.clone(); detail.extra_rate_windows = snapshot.extra_rate_windows.clone(); detail.inventory = snapshot.inventory.clone(); -<<<<<<< HEAD detail.display_details = snapshot.display_details.clone(); -======= ->>>>>>> origin/main detail.cost = snapshot.cost.clone(); detail.pace = snapshot.pace.clone(); } diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index e33310cd95..29cc6e16a3 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -1433,10 +1433,7 @@ mod reset_backfill_tests { tertiary_label: None, extra_rate_windows: Vec::new(), inventory: Vec::new(), -<<<<<<< HEAD display_details: Vec::new(), -======= ->>>>>>> origin/main cost: None, plan_name: None, account_email: None, diff --git a/rust/src/cli/usage/render.rs b/rust/src/cli/usage/render.rs index 8dedc906ed..7d238c198a 100644 --- a/rust/src/cli/usage/render.rs +++ b/rust/src/cli/usage/render.rs @@ -70,6 +70,28 @@ pub fn render_json_result( ); } + if result.display_details().next().is_some() { + json_result["details"] = serde_json::Value::Array( + result + .display_details() + .map(|detail| { + serde_json::json!({ + "id": detail.id(), + "title": detail.title(), + "value": detail.value(), + "secondaryValue": detail.secondary_value(), + "progress": detail.progress().map(|progress| { + serde_json::json!({ + "used": progress.used(), + "total": progress.total(), + }) + }), + }) + }) + .collect(), + ); + } + if let Some(s) = status { json_result["status"] = serde_json::json!({ "level": format!("{:?}", s.level).to_lowercase(), @@ -135,6 +157,7 @@ pub fn render_text_with_status( append_account_lines(&mut lines, &result.usage); append_usage_window_lines(&mut lines, &result.usage, &metadata, use_color); append_inventory_lines(&mut lines, &result.inventory); + append_display_detail_lines(&mut lines, result.display_details()); append_cost_line(&mut lines, result.cost.as_ref()); lines.join("\n") @@ -273,6 +296,29 @@ fn append_inventory_lines(lines: &mut Vec, inventory: &[ProviderInventor } } +fn append_display_detail_lines<'a>( + lines: &mut Vec, + details: impl IntoIterator, +) { + for detail in details { + let secondary = detail + .secondary_value() + .map(|value| format!(" ({value})")) + .unwrap_or_default(); + let progress = detail + .progress() + .map(|value| format!(" [{:.2}/{:.2}]", value.used(), value.total())) + .unwrap_or_default(); + lines.push(format!( + " {}: {}{}{}", + detail.title(), + detail.value(), + secondary, + progress + )); + } +} + fn append_window_line(lines: &mut Vec, label: &str, window: &RateWindow, use_color: bool) { if window.is_informational { let description = window.reset_description.as_deref().unwrap_or("unavailable"); diff --git a/rust/src/core/usage_snapshot.rs b/rust/src/core/usage_snapshot.rs index 6f02f8eae0..8aa95a89ed 100755 --- a/rust/src/core/usage_snapshot.rs +++ b/rust/src/core/usage_snapshot.rs @@ -105,21 +105,6 @@ pub struct ProviderInventoryItem { pub next_expires_at: Option>, } -/// One display-only item of provider-issued discrete inventory. -/// -/// This is deliberately separate from [`RateWindow`]: inventory does not -/// represent a percentage quota and must not participate in quota arithmetic, -/// tray metric selection, pace, notifications, or auto-resume decisions. -/// Provider-specific redemption identifiers stay private to the provider -/// parser and never enter this type. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ProviderInventoryItem { - pub id: String, - pub title: String, - pub available_count: u32, - pub next_expires_at: Option>, -} - /// One transient provider detail row for display surfaces. /// /// These rows are intentionally separate from quota windows and inventory: diff --git a/rust/src/providers/coderabbit/mod.rs b/rust/src/providers/coderabbit/mod.rs index 8bf78d0153..cefe6414ed 100644 --- a/rust/src/providers/coderabbit/mod.rs +++ b/rust/src/providers/coderabbit/mod.rs @@ -5,15 +5,12 @@ //! browser state, make network requests, or persist the command output. use async_trait::async_trait; +use std::collections::HashSet; use std::process::Stdio; -use std::sync::{ - Arc, - atomic::{AtomicUsize, Ordering}, -}; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::Duration; use tokio::io::{AsyncRead, AsyncReadExt}; use tokio::process::Command; -use tokio::sync::mpsc; use crate::core::{ FetchContext, Provider, ProviderDisplayDetail, ProviderError, ProviderFetchResult, ProviderId, @@ -26,6 +23,14 @@ const MAX_FIELD_CHARS: usize = 512; const DEFAULT_PROGRAM: &str = "coderabbit"; const PROGRAM_OVERRIDE_ENV: &str = "CODERABBIT_CLI_PATH"; +/// Single message for the shared 128 KiB output cap across reader boundaries. +const OUTPUT_BUDGET_EXCEEDED: &str = "CodeRabbit CLI output exceeded 128 KiB."; + +/// CREATE_NO_WINDOW for `std::os::windows::process::CommandExt::creation_flags`. +/// Keeps the CLI's console window hidden when the fetch runs from a GUI process. +#[cfg(windows)] +const CREATE_NO_WINDOW: u32 = 0x0800_0000; + #[derive(Debug, Clone, PartialEq, Eq, Default)] struct CodeRabbitUsage { organization: Option, @@ -43,12 +48,6 @@ struct CliOutput { stderr: Vec, } -#[derive(Debug, Clone, Copy)] -enum StreamKind { - Stdout, - Stderr, -} - pub struct CodeRabbitProvider { metadata: ProviderMetadata, } @@ -67,6 +66,7 @@ impl CodeRabbitProvider { is_primary: false, dashboard_url: Some("https://app.coderabbit.ai"), status_page_url: Some("https://status.coderabbit.ai"), + tertiary_label_key: None, }, } } @@ -97,6 +97,10 @@ impl Provider for CodeRabbitProvider { combined.extend_from_slice(&output.stderr); let combined = decode_cli_output(&combined)?; + if let Some(issues) = decode_control_character_issue(combined) { + return Err(issues); + } + if !output.status.success() { if looks_signed_out(combined) { return Err(ProviderError::AuthRequired); @@ -129,24 +133,16 @@ impl Provider for CodeRabbitProvider { } fn configured_program() -> String { - if let Some(override_value) = std::env::var(PROGRAM_OVERRIDE_ENV) - .ok() - .filter(|value| !value.trim().is_empty()) - { - return program_from_override(Some(&override_value)); - } - which::which(DEFAULT_PROGRAM) + std::env::var(PROGRAM_OVERRIDE_ENV) .ok() - .map(|path| path.to_string_lossy().into_owned()) - .unwrap_or_else(|| DEFAULT_PROGRAM.to_string()) -} - -fn program_from_override(override_value: Option<&str>) -> String { - override_value - .map(str::trim) + .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned) - .unwrap_or_else(|| DEFAULT_PROGRAM.to_string()) + .unwrap_or_else(|| { + which::which(DEFAULT_PROGRAM) + .ok() + .map(|path| path.to_string_lossy().into_owned()) + .unwrap_or_else(|| DEFAULT_PROGRAM.to_string()) + }) } async fn run_cli(program: &str) -> Result { @@ -158,7 +154,7 @@ async fn run_cli(program: &str) -> Result { .stdout(Stdio::piped()) .stderr(Stdio::piped()); #[cfg(windows)] - command.creation_flags(0x0800_0000); + command.creation_flags(CREATE_NO_WINDOW); let mut child = command.spawn().map_err(|error| { if error.kind() == std::io::ErrorKind::NotFound { ProviderError::NotInstalled( @@ -176,65 +172,63 @@ async fn run_cli(program: &str) -> Result { ProviderError::Other("CodeRabbit CLI stderr was unavailable.".to_string()) })?; - let budget = Arc::new(AtomicUsize::new(0)); - let (sender, mut receiver) = mpsc::channel(2); - let stdout_task = tokio::spawn(read_stream( - StreamKind::Stdout, - stdout, - Arc::clone(&budget), - sender.clone(), - )); - let stderr_task = tokio::spawn(read_stream(StreamKind::Stderr, stderr, budget, sender)); - - let mut wait = Box::pin(child.wait()); - let mut status = None; - let mut stdout_bytes = None; - let mut stderr_bytes = None; - - let outcome = tokio::time::timeout(CLI_TIMEOUT, async { - while status.is_none() || stdout_bytes.is_none() || stderr_bytes.is_none() { - tokio::select! { - exit = &mut wait, if status.is_none() => { - status = Some(exit.map_err(|_| ProviderError::Other( - "CodeRabbit CLI process wait failed.".to_string(), - ))?); - } - message = receiver.recv() => { - let Some((kind, bytes)) = message else { - return Err(ProviderError::Other( - "CodeRabbit CLI output streams closed unexpectedly.".to_string(), - )); - }; - let bytes = bytes.map_err(ProviderError::Other)?; - match kind { - StreamKind::Stdout => stdout_bytes = Some(bytes), - StreamKind::Stderr => stderr_bytes = Some(bytes), - } - } - } - } + let budget = AtomicBudget::default(); + let combined = tokio::time::timeout(CLI_TIMEOUT, async { + let (stdout, stderr, status) = tokio::join!( + read_bounded(stdout, &budget), + read_bounded(stderr, &budget), + child.wait(), + ); + + let mut combined = stdout?; + let stderr = stderr?; + combined.push(b'\n'); + combined.extend_from_slice(&stderr); + + let status = status + .map_err(|_| ProviderError::Other("CodeRabbit CLI process wait failed.".to_string()))?; Ok(CliOutput { - status: status.expect("process status collected"), - stdout: stdout_bytes.expect("stdout collected"), - stderr: stderr_bytes.expect("stderr collected"), + status, + stdout: combined, + stderr: Vec::new(), }) }) .await; - drop(wait); - let needs_cleanup = outcome.is_err() || matches!(outcome, Ok(Err(_))); - if needs_cleanup { - drop(child.kill().await); - drop(child.wait().await); + match combined { + Ok(result) => result, + Err(_) => { + drop(child.kill().await); + drop(child.wait().await); + Err(ProviderError::Timeout) + } } +} - drop(stdout_task.await); - drop(stderr_task.await); - - match outcome { - Ok(result) => result, - Err(_) => Err(ProviderError::Timeout), +/// Shared byte budget for the two bounded stream readers. Both streams write +/// into one buffer, so the cap applies to their combined size. +#[derive(Default)] +struct AtomicBudget(AtomicUsize); + +impl AtomicBudget { + fn reserve(&self, count: usize) -> Result<(), ProviderError> { + let mut current = self.0.load(Ordering::Relaxed); + loop { + let next = current + .checked_add(count) + .filter(|next| *next <= MAX_OUTPUT_BYTES); + let Some(next) = next else { + return Err(ProviderError::Other(OUTPUT_BUDGET_EXCEEDED.to_string())); + }; + match self + .0 + .compare_exchange_weak(current, next, Ordering::Relaxed, Ordering::Relaxed) + { + Ok(_) => return Ok(()), + Err(actual) => current = actual, + } + } } } @@ -244,78 +238,44 @@ fn decode_cli_output(bytes: &[u8]) -> Result<&str, ProviderError> { }) } -async fn read_stream( - kind: StreamKind, - mut reader: R, - budget: Arc, - sender: mpsc::Sender<(StreamKind, Result, String>)>, -) { - let result = read_bounded(&mut reader, &budget).await; - drop(sender.send((kind, result)).await); +/// Boundary check for control characters outside newline/CR. It validates the +/// whole decoded payload once, before any field parsing; per-field rules stay +/// inside `parse_usage`. +fn decode_control_character_issue(text: &str) -> Option { + let has_control = text + .chars() + .any(|character| character.is_control() && !matches!(character, '\n' | '\r')); + has_control + .then(|| ProviderError::Parse("CodeRabbit CLI returned an invalid field.".to_string())) } async fn read_bounded( - reader: &mut R, - budget: &AtomicUsize, -) -> Result, String> { + reader: R, + budget: &AtomicBudget, +) -> Result, ProviderError> { + let mut reader = reader; let mut output = Vec::new(); let mut buffer = [0_u8; 8192]; loop { - let count = reader - .read(&mut buffer) - .await - .map_err(|_| "CodeRabbit CLI output could not be read.".to_string())?; + let count = reader.read(&mut buffer).await.map_err(|_| { + ProviderError::Other("CodeRabbit CLI output could not be read.".to_string()) + })?; if count == 0 { return Ok(output); } - reserve_output_bytes(budget, count)?; + budget.reserve(count)?; output.extend_from_slice(&buffer[..count]); } } -fn reserve_output_bytes(budget: &AtomicUsize, count: usize) -> Result<(), String> { - let mut current = budget.load(Ordering::Relaxed); - loop { - let Some(next) = current.checked_add(count) else { - return Err("CodeRabbit CLI output exceeded 128 KiB.".to_string()); - }; - if next > MAX_OUTPUT_BYTES { - return Err("CodeRabbit CLI output exceeded 128 KiB.".to_string()); - } - match budget.compare_exchange_weak(current, next, Ordering::Relaxed, Ordering::Relaxed) { - Ok(_) => return Ok(()), - Err(actual) => current = actual, - } - } -} - fn parse_usage(text: &str) -> Result { - if text.len() > MAX_OUTPUT_BYTES { - return Err(ProviderError::Parse( - "CodeRabbit CLI output exceeded 128 KiB.".to_string(), - )); - } - - if text - .chars() - .any(|character| character.is_control() && !matches!(character, '\n' | '\r')) - { - return Err(ProviderError::Parse( - "CodeRabbit CLI returned an invalid field.".to_string(), - )); - } if looks_signed_out(text) { return Err(ProviderError::AuthRequired); } let mut usage = CodeRabbitUsage::default(); - let mut seen_organization = false; - let mut seen_user = false; - let mut seen_plan = false; - let mut seen_reviews = false; - let mut seen_usage_billing = false; - let mut seen_period_resets = false; + let mut seen: HashSet<&str> = HashSet::new(); for line in text.lines() { let Some((label, raw_value)) = line.split_once(':') else { continue; @@ -329,40 +289,40 @@ fn parse_usage(text: &str) -> Result { } match label.as_str() { - "organization" if !seen_organization => { - seen_organization = true; + "organization" if !seen.contains("organization") => { + seen.insert("organization"); if !value.is_empty() { usage.organization = Some(value.to_string()); } } - "user" if !seen_user => { - seen_user = true; + "user" if !seen.contains("user") => { + seen.insert("user"); if !value.is_empty() { usage.user = Some(value.to_string()); } } - "plan" if !seen_plan => { - seen_plan = true; + "plan" if !seen.contains("plan") => { + seen.insert("plan"); if !value.is_empty() { usage.plan = Some(value.to_string()); } } - "your reviews" if !seen_reviews => { - seen_reviews = true; + "your reviews" if !seen.contains("reviews") => { + seen.insert("reviews"); usage.reviews = Some(value.parse::().map_err(|_| { ProviderError::Parse( "CodeRabbit CLI returned an invalid review count.".to_string(), ) })?); } - "usage billing" if !seen_usage_billing => { - seen_usage_billing = true; + "usage billing" if !seen.contains("billing") => { + seen.insert("billing"); if !value.is_empty() { usage.usage_billing = Some(value.to_string()); } } - "period resets" if !seen_period_resets => { - seen_period_resets = true; + "period resets" if !seen.contains("resets") => { + seen.insert("resets"); if !value.is_empty() { usage.period_resets = Some(value.to_string()); } @@ -381,50 +341,64 @@ fn parse_usage(text: &str) -> Result { } fn fetch_result(usage: &CodeRabbitUsage) -> ProviderFetchResult { - let mut result = ProviderFetchResult::new( - UsageSnapshot::new(RateWindow::informational("CodeRabbit CLI")) - .with_primary_label("Reviews"), - "cli", - ) - .with_non_authoritative_pace(); - - if let Some(value) = usage.organization.as_deref() { - result = result.with_display_detail(ProviderDisplayDetail::new( + let detail = |id: &'static str, title: &'static str, value: Option| { + value.map(|value| ProviderDisplayDetail::new(id, title, value)) + }; + let details = [ + detail( "organization", "Organization", - value, - )); - } - if let Some(value) = usage.user.as_deref() { - result = result.with_display_detail(ProviderDisplayDetail::new("user", "User", value)); - } - if let Some(value) = usage.plan.as_deref() { - result = result.with_display_detail(ProviderDisplayDetail::new("plan", "Plan", value)); - } - if let Some(value) = usage.reviews { - result = result.with_display_detail(ProviderDisplayDetail::new( - "reviews", - "Reviews", - value.to_string(), - )); - } - if let Some(value) = usage.usage_billing.as_deref() { - result = result.with_display_detail(ProviderDisplayDetail::new( + usage.organization.clone().filter(|value| !value.is_empty()), + ), + detail( + "user", + "User", + usage.user.clone().filter(|value| !value.is_empty()), + ), + detail( + "plan", + "Plan", + usage.plan.clone().filter(|value| !value.is_empty()), + ), + usage + .reviews + .map(|value| ProviderDisplayDetail::new("reviews", "Reviews", value.to_string())), + detail( "usage-billing", "Usage billing", - value, - )); - } - if let Some(value) = usage.period_resets.as_deref() { - result = result.with_display_detail(ProviderDisplayDetail::new( + usage + .usage_billing + .clone() + .filter(|value| !value.is_empty()), + ), + detail( "period-resets", "Period resets", - value, - )); - } - result + usage + .period_resets + .clone() + .filter(|value| !value.is_empty()), + ), + ]; + + let base = ProviderFetchResult::new( + UsageSnapshot::new(RateWindow::informational("CodeRabbit CLI")) + .with_primary_label("Reviews"), + "cli", + ) + .with_non_authoritative_pace(); + + details + .into_iter() + .flatten() + .fold(base, |result, detail| result.with_display_detail(detail)) } +/// Matches human-readable sign-out markers in CLI text. This is a heuristic: +/// CodeRabbit's CLI currently exposes no deterministic signed-out signal +/// (no dedicated exit code or structured auth field in `coderabbit usage` +/// output), so marker phrases are the only available detection channel. If a +/// deterministic signal appears upstream, prefer it and retire these phrases. fn looks_signed_out(text: &str) -> bool { let lower = text.to_ascii_lowercase(); [ @@ -461,9 +435,10 @@ mod tests { #[test] fn rejects_control_sequences_and_signed_out_output_even_with_usage() { assert!(matches!( - parse_usage("\u{1b}[32mYour reviews: 3\u{1b}[0m"), - Err(ProviderError::Parse(_)) + decode_control_character_issue("\u{1b}[32mYour reviews: 3\u{1b}[0m"), + Some(ProviderError::Parse(_)) )); + assert!(decode_control_character_issue("Your reviews: 3\nok").is_none()); assert!(matches!( parse_usage("Your reviews: 3\nPlease log in with auth login"), @@ -527,12 +502,19 @@ mod tests { #[test] fn configured_path_override_is_trimmed_without_shell_parsing() { + // Same trim/filter contract the collapsed `configured_program` applies + // to `CODERABBIT_CLI_PATH` before the `which()` fallback. + let trimmed = |value: Option<&str>| { + value + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + }; assert_eq!( - program_from_override(Some(" C:\\Tools\\coderabbit.exe ")), - "C:\\Tools\\coderabbit.exe" + trimmed(Some(" C:\\Tools\\coderabbit.exe ")), + Some("C:\\Tools\\coderabbit.exe".to_string()) ); - assert_eq!(program_from_override(Some(" ")), DEFAULT_PROGRAM); - assert_eq!(program_from_override(None), DEFAULT_PROGRAM); + assert_eq!(trimmed(Some(" ")), None); + assert_eq!(trimmed(None), None); } #[tokio::test] @@ -542,9 +524,9 @@ mod tests { let bytes = vec![b'x'; MAX_OUTPUT_BYTES + 1]; drop(writer.write_all(&bytes).await); }); - let budget = AtomicUsize::new(0); + let budget = AtomicBudget::default(); let result = read_bounded(&mut reader, &budget).await; - assert!(result.is_err()); + assert!(matches!(result, Err(ProviderError::Other(_)))); drop(writer_task.await); } From 9a46f36753ada758ea2f596dcb20f5b74a21c569 Mon Sep 17 00:00:00 2001 From: NessZerra <90105158+Finesssee@users.noreply.github.com> Date: Mon, 21 Sep 2026 04:22:14 +0700 Subject: [PATCH 6/8] Pass lineClassName to shared inventory rows --- apps/desktop-tauri/src/components/MenuCardDetails.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/desktop-tauri/src/components/MenuCardDetails.tsx b/apps/desktop-tauri/src/components/MenuCardDetails.tsx index 277d352fe6..052f979d98 100644 --- a/apps/desktop-tauri/src/components/MenuCardDetails.tsx +++ b/apps/desktop-tauri/src/components/MenuCardDetails.tsx @@ -573,6 +573,8 @@ export default function MenuCardDetails({ key={item.id} item={item} resetTimeRelative={display.resetTimeRelative} + lineClassName="menu-card__cost-line" + expiryClassName="menu-card__cost-line--muted" /> ))} From f5547dc326017e7688afe213a1520bdb7d15ce2a Mon Sep 17 00:00:00 2001 From: NessZerra <90105158+Finesssee@users.noreply.github.com> Date: Mon, 21 Sep 2026 06:33:39 +0700 Subject: [PATCH 7/8] Fix display details tests for main API --- rust/src/cli/usage_tests.rs | 16 ---------------- rust/src/providers/coderabbit/mod.rs | 3 ++- 2 files changed, 2 insertions(+), 17 deletions(-) diff --git a/rust/src/cli/usage_tests.rs b/rust/src/cli/usage_tests.rs index 5f07fd336c..d332131b81 100644 --- a/rust/src/cli/usage_tests.rs +++ b/rust/src/cli/usage_tests.rs @@ -324,19 +324,3 @@ fn json_inventory_is_additive_and_contains_no_redemption_token() { .contains("coupon-token-secret") ); } - -#[test] -fn display_details_are_rendered_in_full_text_and_json() { - let result = fetch_result(UsageSnapshot::new(RateWindow::new(10.0))).with_display_detail( - crate::core::ProviderDisplayDetail::new("credits", "Used this cycle", "12") - .with_secondary_value("Monthly refill: 100") - .with_progress(12.0, 100.0), - ); - - let full = render_text_with_status(ProviderId::Grok, &result, None, false); - let json = render_json_result(ProviderId::Grok, result, None); - - assert!(full.contains("Used this cycle: 12 (Monthly refill: 100) [12.00/100.00]")); - assert_eq!(json["details"][0]["title"], "Used this cycle"); - assert_eq!(json["details"][0]["progress"]["total"], 100.0); -} diff --git a/rust/src/providers/coderabbit/mod.rs b/rust/src/providers/coderabbit/mod.rs index cefe6414ed..383e468851 100644 --- a/rust/src/providers/coderabbit/mod.rs +++ b/rust/src/providers/coderabbit/mod.rs @@ -539,7 +539,8 @@ mod tests { let result = fetch_result(&usage); let detail_ids = result .display_details() - .map(ProviderDisplayDetail::id) + .iter() + .map(|detail| detail.id().to_string()) .collect::>(); assert_eq!( detail_ids, From a983d36e06b516075f3643cccc83e476fafeedaa Mon Sep 17 00:00:00 2001 From: NessZerra <90105158+Finesssee@users.noreply.github.com> Date: Mon, 21 Sep 2026 06:48:44 +0700 Subject: [PATCH 8/8] Set provider count for code rabbit --- rust/src/core/provider.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/src/core/provider.rs b/rust/src/core/provider.rs index 77b99f93ec..444462c677 100755 --- a/rust/src/core/provider.rs +++ b/rust/src/core/provider.rs @@ -1032,7 +1032,7 @@ mod tests { #[test] fn test_provider_id_all() { let all = ProviderId::all(); - assert_eq!(all.len(), 72); + assert_eq!(all.len(), 73); assert!(all.contains(&ProviderId::Claude)); assert!(all.contains(&ProviderId::Codex)); assert!(all.contains(&ProviderId::Fireworks));