Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ menu-bar layout.
- Provider charts: show recent Codex and Claude quota-window history with explicit account scope and estimated-boundary markers; historical data remains display-only.
- Grok: Settings and tray **Add account** flow matching Codex/Claude — isolated `grok login --oauth`, save current CLI login, switch, and remove without logging out the active session.
- Replicate: cookie-authenticated monthly spend and optional prepaid credit balance from the billing page, with user and organization account isolation.
- Raycast: read monthly AI credits from the signed-in `www.raycast.com` session (Chrome auto-import or a pasted Cookie header), showing credits left, plan, and renewal date. Off makes no request and a manual header never falls back to a browser.
- DeepSeek: show reported per-model spend in the provider details while preserving the billing currency, reporting period, zero values, and incomplete-total safeguards.

### Fixed
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ See the full history in [CHANGELOG.md](CHANGELOG.md).
| OpenAI | Admin API / API Key | Usage, Requests, Project-scoped cost, Credit Balance |
| Grok | Cookies / auth.json | Billing |
| Helmcode (also NaN Builders) | Browser cookies / manual Cookie header | Per-model token quotas, reset windows, Helmcode prepaid balance |
| Raycast | Cookies (Chrome auto or manual) | Monthly AI credits, plan, renewal |
| Replicate | Cookies / token accounts | Monthly spend, credit balance |
| ElevenLabs | API Key | Subscription Credits, Voice Slots |
| Deepgram | API Key | Project Usage |
Expand Down
1 change: 1 addition & 0 deletions apps/desktop-tauri/src-tauri/src/auto_resume_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ mod tests {
window_minutes: Some(300),
resets_at: Some(Utc::now().to_rfc3339()),
reset_description: None,
detail_description: None,
is_exhausted: exhausted,
is_informational: informational,
reserve_percent: None,
Expand Down
24 changes: 24 additions & 0 deletions apps/desktop-tauri/src-tauri/src/commands/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ pub struct RateWindowSnapshot {
pub resets_at: Option<String>,
#[serde(default)]
pub reset_description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail_description: Option<String>,
#[serde(default)]
pub is_exhausted: bool,
#[serde(default)]
Expand Down Expand Up @@ -47,6 +49,7 @@ impl RateWindowSnapshot {
window_minutes: rw.window_minutes,
resets_at: rw.resets_at.map(|dt| dt.to_rfc3339()),
reset_description: rw.reset_description.clone(),
detail_description: rw.detail_description().map(str::to_owned),
is_exhausted: rw.is_exhausted(),
is_informational: rw.is_informational,
reserve_percent: None,
Expand Down Expand Up @@ -512,6 +515,7 @@ impl ProviderUsageSnapshot {
window_minutes: None,
resets_at: None,
reset_description: None,
detail_description: None,
is_exhausted: false,
is_informational: false,
reserve_percent: None,
Expand Down Expand Up @@ -974,6 +978,7 @@ mod tests {
window_minutes,
resets_at: resets_at.map(|dt| dt.to_rfc3339()),
reset_description,
detail_description: None,
is_exhausted: false,
is_informational: false,
reserve_percent: None,
Expand Down Expand Up @@ -1009,6 +1014,25 @@ mod tests {
);
}

#[test]
fn detail_description_crosses_the_bridge_and_is_not_a_reset_phrase() {
let rw = RateWindow::with_details(25.0, None, None, Some("750 / 1000 credits left".into()))
.with_detail_description("750 / 1000 credits left");
let window = RateWindowSnapshot::from_rate_window(&rw);

assert_eq!(
window.detail_description.as_deref(),
Some("750 / 1000 credits left")
);
let json = serde_json::to_value(&window).unwrap();
assert_eq!(json["detailDescription"], "750 / 1000 credits left");
assert_eq!(compact_tray_status_label(&window, Language::English), "25%");

let plain = RateWindowSnapshot::from_rate_window(&RateWindow::new(10.0));
let json = serde_json::to_value(&plain).unwrap();
assert!(json.get("detailDescription").is_none());
}

#[test]
fn japanese_tray_status_label_has_no_english_reset_text() {
use codexbar::settings::Language;
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop-tauri/src-tauri/src/commands/bridge/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ fn compact_reset_description(
.map(|dt| dt.with_timezone(&chrono::Utc))?;
return Some(format_compact_reset_countdown(dt, lang));
}
// A window with its own detail line (e.g. a credit balance) carries that
// text in `reset_description` too; it is not a reset phrase.
if window.detail_description.is_some() {
return None;
}

window
.reset_description
Expand Down
27 changes: 27 additions & 0 deletions apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ fn cookie_source_provider(provider_id: &str) -> Option<codexbar::core::ProviderI
"notion" => ProviderId::Notion,
"grok" => ProviderId::Grok,
"replicate" => ProviderId::Replicate,
"raycast" => ProviderId::Raycast,
"helmcode" => ProviderId::Helmcode,
"typesafe" => ProviderId::TypeSafe,
_ => return None,
Expand Down Expand Up @@ -749,6 +750,32 @@ pub fn cookie_source_options_for(provider_id: &str, lang: Language) -> Vec<Cooki
None,
),
],
"raycast" => vec![
cookie_option(
lang,
"auto",
locale::get_text(lang, locale::LocaleKey::ProviderRaycastAutoImportHelp),
"",
None,
),
cookie_option(
lang,
"manual",
"",
locale::get_text(lang, locale::LocaleKey::ProviderRaycastManualCookieHelp),
None,
),
cookie_option(
lang,
"off",
"",
"",
Some(
locale::get_text(lang, locale::LocaleKey::ProviderRaycastCookiesDisabled)
.as_str(),
),
),
],
"helmcode" => vec![
cookie_option(
lang,
Expand Down
1 change: 1 addition & 0 deletions apps/desktop-tauri/src-tauri/src/commands/providers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1314,6 +1314,7 @@ mod reset_backfill_tests {
window_minutes: Some(300),
resets_at: resets_at.map(String::from),
reset_description: None,
detail_description: None,
is_exhausted: false,
is_informational: false,
reserve_percent: None,
Expand Down
64 changes: 64 additions & 0 deletions apps/desktop-tauri/src-tauri/src/commands/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,20 @@ fn replicate_cookie_source_and_domain_are_exposed() {
);
}

#[test]
fn raycast_cookie_source_and_domain_are_exposed() {
let mut settings = Settings::default();
super::provider_cookie_source_set(&mut settings, "raycast", "manual".to_string()).unwrap();
assert_eq!(
provider_cookie_source_lookup(&settings, "raycast").as_deref(),
Some("manual")
);
assert_eq!(
super::provider_cookie_domain(ProviderId::Raycast, &settings),
Some("www.raycast.com")
);
}

#[test]
fn provider_cookie_source_set_rejects_unknown_provider() {
let mut s = Settings::default();
Expand Down Expand Up @@ -485,6 +499,49 @@ fn fetch_context_replicate_empty_manual_fails_closed_without_browser_import() {
assert!(ctx.manual_cookie_missing);
}

#[test]
fn fetch_context_raycast_cookie_sources_never_import_in_the_shell() {
let build = |source: &str, stored: Option<&str>| {
let mut settings = Settings::default();
settings.set_cookie_source(ProviderId::Raycast, source);
let mut cookies = ManualCookies::default();
if let Some(stored) = stored {
cookies.set(ProviderId::Raycast.cli_name(), stored);
}
super::build_fetch_context(
ProviderId::Raycast,
&settings,
&cookies,
&ApiKeys::default(),
&HashMap::new(),
)
};

// Manual with no stored header fails closed instead of using a browser.
let empty_manual = build("manual", None);
assert_eq!(empty_manual.source_mode, SourceMode::Web);
assert!(empty_manual.manual_cookie_header.is_none());
assert!(empty_manual.manual_cookie_missing);

// Off maps to the source the provider refuses, and never carries a header.
let off = build("off", Some("__raycast_session=stored"));
assert_eq!(off.source_mode, SourceMode::Cli);
assert!(off.manual_cookie_header.is_none());

// Auto leaves browser resolution to the provider (Chrome only).
let auto = build("auto", None);
assert_eq!(auto.source_mode, SourceMode::Auto);
assert!(auto.manual_cookie_header.is_none());
assert!(!auto.manual_cookie_missing);

let manual = build("manual", Some("__raycast_session=stored"));
assert_eq!(manual.source_mode, SourceMode::Web);
assert_eq!(
manual.manual_cookie_header.as_deref(),
Some("__raycast_session=stored")
);
}

#[test]
fn fetch_context_codex_manual_cookie_never_forces_unsupported_web() {
// Default cookie source is "manual". Pasting a chatgpt.com cookie used to flip
Expand Down Expand Up @@ -1779,6 +1836,13 @@ fn replicate_cookie_options_allow_automatic_and_manual_sessions() {
assert_eq!(values, vec!["auto", "manual"]);
}

#[test]
fn raycast_cookie_options_include_off_and_a_pinned_manual_session() {
let opts = super::cookie_source_options_for("raycast", Language::English);
let values: Vec<_> = opts.iter().map(|option| option.value.as_str()).collect();
assert_eq!(values, vec!["auto", "manual", "off"]);
}

#[test]
fn cookie_options_empty_for_providers_without_picker() {
assert!(super::cookie_source_options_for("anthropic", Language::English).is_empty());
Expand Down
1 change: 1 addition & 0 deletions apps/desktop-tauri/src-tauri/src/powertoys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ mod tests {
window_minutes: None,
resets_at: None,
reset_description: None,
detail_description: None,
is_exhausted: false,
is_informational: false,
reserve_percent: None,
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop-tauri/src-tauri/src/tray_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1028,6 +1028,7 @@ mod tests {
window_minutes: None,
resets_at: None,
reset_description: None,
detail_description: None,
is_exhausted: false,
is_informational: false,
reserve_percent: None,
Expand All @@ -1042,6 +1043,7 @@ mod tests {
window_minutes: None,
resets_at: None,
reset_description: None,
detail_description: None,
is_exhausted: false,
is_informational: false,
reserve_percent: None,
Expand All @@ -1057,6 +1059,7 @@ mod tests {
window_minutes: None,
resets_at: None,
reset_description: None,
detail_description: None,
is_exhausted: false,
is_informational: false,
reserve_percent: None,
Expand Down Expand Up @@ -1121,6 +1124,7 @@ mod tests {
window_minutes: None,
resets_at: None,
reset_description: None,
detail_description: None,
is_exhausted: false,
is_informational: false,
reserve_percent: None,
Expand Down Expand Up @@ -1531,6 +1535,7 @@ mod tests {
window_minutes: Some(10080),
resets_at: None,
reset_description: None,
detail_description: None,
is_exhausted: true,
is_informational: false,
reserve_percent: None,
Expand Down Expand Up @@ -1575,6 +1580,7 @@ mod tests {
window_minutes: None,
resets_at: None,
reset_description: None,
detail_description: None,
is_exhausted: false,
is_informational: false,
reserve_percent: None,
Expand Down
1 change: 1 addition & 0 deletions apps/desktop-tauri/src-tauri/src/usage_metric.rs
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ fn derived_window(used_percent: f64, resets_at: Option<String>) -> RateWindowSna
window_minutes: None,
resets_at,
reset_description: None,
detail_description: None,
is_exhausted: used_percent >= 100.0,
is_informational: false,
reserve_percent: None,
Expand Down
32 changes: 32 additions & 0 deletions apps/desktop-tauri/src/components/MenuCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,38 @@ describe("MenuCard", () => {
expect(fill?.style.width).toBe("35%");
});

it("shows an additional balance description below the meter and reset time", async () => {
const snapshot = provider(null, 25, {
resetDescription: "750 / 1000 credits left",
});
snapshot.primaryLabel = "Credits";
snapshot.primary.detailDescription = "750 / 1000 credits left";
snapshot.selectedMetric.detailDescription = "750 / 1000 credits left";
const resetsAt = new Date(Date.now() + 60 * 60 * 1000).toISOString();
snapshot.primary.resetsAt = resetsAt;
snapshot.selectedMetric.resetsAt = resetsAt;

renderCard(snapshot);

const description = await screen.findByText("750 / 1000 credits left");
expect(description).toHaveClass("menu-metric__detail");
expect(screen.getByText(/Resets in/)).toBeInTheDocument();
});

it("does not repeat a reset-phrase description as an extra detail", async () => {
const snapshot = provider(null, 20, { resetDescription: "Resets in 3h" });
const resetsAt = new Date(Date.now() + 5 * 60 * 60 * 1000).toISOString();
snapshot.primary.resetsAt = resetsAt;
snapshot.selectedMetric.resetsAt = resetsAt;

renderCard(snapshot);

await screen.findByText(/Resets in/);
expect(document.querySelectorAll(".menu-metric__reset")).toHaveLength(1);
expect(document.querySelectorAll(".menu-metric__detail")).toHaveLength(0);
expect(screen.queryByText("Resets in 3h")).not.toBeInTheDocument();
});

it("displays over-quota usage without overflowing the bar", async () => {
renderCard(provider(null, 115, { exhausted: true, resetDescription: "115% used" }), {
showAsUsed: true,
Expand Down
6 changes: 5 additions & 1 deletion apps/desktop-tauri/src/components/MenuCardDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -339,10 +339,11 @@ function MetricRow({
const level = levelOf(remain, snap.isExhausted);
const resetText = useFormattedResetTime(
snap.resetsAt,
isInformational ? null : snap.resetDescription,
isInformational || snap.detailDescription ? null : snap.resetDescription,
resetTimeRelative,
resetFormatMode ?? "reset",
);
const detailDescription = snap.detailDescription?.trim() || null;
const infoPrimary = snap.resetDescription?.trim() || resetText || "—";
const resetTarget = snap.resetsAt ? Date.parse(snap.resetsAt) : Number.NaN;
const replacesPercent =
Expand Down Expand Up @@ -380,6 +381,9 @@ function MetricRow({
<span className="menu-metric__reset">{resetText}</span>
)}
</div>
{!compactOverview && !isInformational && detailDescription && (
<div className="menu-metric__detail">{detailDescription}</div>
)}
{!compactOverview && !isInformational && snap.isExhausted && (
<div className="menu-metric__exhausted">{exhaustedLabel}</div>
)}
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions apps/desktop-tauri/src/components/providers/providerIcons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import opencodego from "./icons/ProviderIcon-opencodego.svg?raw";
import openrouter from "./icons/ProviderIcon-openrouter.svg?raw";
import perplexity from "./icons/ProviderIcon-perplexity.svg?raw";
import qoder from "./icons/ProviderIcon-qoder.svg?raw";
import raycast from "./icons/ProviderIcon-raycast.svg?raw";
import replicate from "./icons/ProviderIcon-replicate.svg?raw";
import sakana from "./icons/ProviderIcon-sakana.svg?raw";
import stepfun from "./icons/ProviderIcon-stepfun.svg?raw";
Expand Down Expand Up @@ -142,6 +143,7 @@ const RAW: Record<string, string> = {
openrouter: tint(openrouter),
perplexity: tint(perplexity),
qoder: tint(qoder),
raycast: tint(raycast),
replicate: tint(replicate),
sakana: tint(sakana),
stepfun: tint(stepfun),
Expand Down Expand Up @@ -224,6 +226,7 @@ export const PROVIDER_ICON_REGISTRY: Record<string, ProviderIcon> = {
crof: { id: "crof", brandColor: "#7c3aed", fallbackLetter: "C", svgPath: RAW.crof },
crossmodel: { id: "crossmodel", brandColor: "#c084fc", fallbackLetter: "X", svgPath: RAW.crossmodel },
qoder: { id: "qoder", brandColor: "#2563eb", fallbackLetter: "Q", svgPath: RAW.qoder },
raycast: { id: "raycast", brandColor: "#FF6363", fallbackLetter: "R", svgPath: RAW.raycast },
replicate: { id: "replicate", brandColor: "#000000", fallbackLetter: "R", svgPath: RAW.replicate },
codebuddy: { id: "codebuddy", brandColor: "#0052d9", fallbackLetter: "C" },
sakana: { id: "sakana", brandColor: "#0ea5e9", fallbackLetter: "S", svgPath: RAW.sakana },
Expand Down
Loading