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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 68 additions & 2 deletions src/app_settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ pub struct SettingsFile {
show_codex: bool,
#[serde(default)]
show_antigravity: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub show_antigravity_gemini: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub show_antigravity_external: Option<bool>,
#[serde(default)]
show_opencode: bool,
#[serde(default)]
Expand Down Expand Up @@ -127,6 +131,8 @@ impl Default for SettingsFile {
show_claude_code: true,
show_codex: false,
show_antigravity: false,
show_antigravity_gemini: None,
show_antigravity_external: None,
show_opencode: false,
show_cursor: false,
show_grok: false,
Expand Down Expand Up @@ -158,6 +164,14 @@ impl SettingsFile {
{
self.poll_interval_ms = default_poll_interval();
}
if self.show_antigravity_gemini.is_none() {
self.show_antigravity_gemini = Some(self.show_antigravity);
}
if self.show_antigravity_external.is_none() {
self.show_antigravity_external = Some(self.show_antigravity);
}
self.show_antigravity = self.show_antigravity_gemini.unwrap_or(false)
|| self.show_antigravity_external.unwrap_or(false);
if self.enabled_providers().is_empty() {
self.set_enabled_providers(ProviderSet::default());
}
Expand Down Expand Up @@ -210,19 +224,52 @@ impl SettingsFile {
match provider {
ProviderId::Claude => self.show_claude_code,
ProviderId::Codex => self.show_codex,
ProviderId::Antigravity => self.show_antigravity,
ProviderId::Antigravity => {
self.show_antigravity_gemini.unwrap_or(self.show_antigravity)
|| self.show_antigravity_external.unwrap_or(self.show_antigravity)
}
ProviderId::OpenCode => self.show_opencode,
ProviderId::Cursor => self.show_cursor,
ProviderId::Grok => self.show_grok,
ProviderId::Copilot => self.show_copilot,
}
}

pub fn antigravity_gemini_enabled(&self) -> bool {
self.show_antigravity_gemini.unwrap_or(self.show_antigravity)
}

pub fn antigravity_external_enabled(&self) -> bool {
self.show_antigravity_external.unwrap_or(self.show_antigravity)
}

#[allow(dead_code)]
pub fn set_antigravity_gemini_enabled(&mut self, enabled: bool) {
self.show_antigravity_gemini = Some(enabled);
self.show_antigravity = enabled || self.antigravity_external_enabled();
}

#[allow(dead_code)]
pub fn set_antigravity_external_enabled(&mut self, enabled: bool) {
self.show_antigravity_external = Some(enabled);
self.show_antigravity = self.antigravity_gemini_enabled() || enabled;
}

pub fn set_provider_enabled(&mut self, provider: ProviderId, enabled: bool) {
match provider {
ProviderId::Claude => self.show_claude_code = enabled,
ProviderId::Codex => self.show_codex = enabled,
ProviderId::Antigravity => self.show_antigravity = enabled,
ProviderId::Antigravity => {
// Re-saving an already enabled Antigravity keeps the user's
// per-pool choice; only an off/on transition resets both pools.
let was_enabled =
self.antigravity_gemini_enabled() || self.antigravity_external_enabled();
if enabled != was_enabled {
self.show_antigravity_gemini = Some(enabled);
self.show_antigravity_external = Some(enabled);
}
self.show_antigravity = enabled;
}
ProviderId::OpenCode => self.show_opencode = enabled,
ProviderId::Cursor => self.show_cursor = enabled,
ProviderId::Grok => self.show_grok = enabled,
Expand Down Expand Up @@ -698,4 +745,23 @@ mod tests {
assert_eq!(invalid.dashboard_width, None);
assert_eq!(invalid.dashboard_height, None);
}

#[test]
fn resaving_enabled_providers_keeps_antigravity_pool_choice() {
let mut settings = SettingsFile::default();
settings.set_provider_enabled(ProviderId::Antigravity, true);
settings.set_antigravity_gemini_enabled(false);
settings.set_enabled_providers(ProviderSet::from_enabled([
ProviderId::Claude,
ProviderId::Antigravity,
]));
assert!(!settings.antigravity_gemini_enabled());
assert!(settings.antigravity_external_enabled());

settings.set_provider_enabled(ProviderId::Antigravity, false);
assert!(!settings.antigravity_gemini_enabled());
assert!(!settings.antigravity_external_enabled());
settings.set_provider_enabled(ProviderId::Antigravity, true);
assert!(settings.antigravity_gemini_enabled() && settings.antigravity_external_enabled());
}
}
15 changes: 10 additions & 5 deletions src/context_menu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ pub enum ContextMenuAction {
ToggleProvider {
provider: ContextMenuProvider,
},
ToggleAntigravityGemini,
ToggleAntigravityExternal,
ToggleStartup,
ToggleTaskbarLock,
ToggleWidget,
Expand Down Expand Up @@ -385,11 +387,14 @@ pub fn classic_context_menu() -> ContextMenuDocument {
},
),
ContextMenuItem::action(
"provider-antigravity",
"Antigravity",
Action::ToggleProvider {
provider: Provider::Antigravity,
},
"provider-antigravity-gemini",
"Antigravity (Gemini)",
Action::ToggleAntigravityGemini,
),
ContextMenuItem::action(
"provider-antigravity-external",
"Antigravity (Claude + GPT)",
Action::ToggleAntigravityExternal,
),
ContextMenuItem::action(
"provider-opencode",
Expand Down
144 changes: 139 additions & 5 deletions src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,29 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};

use crate::providers::ProviderId;

#[derive(Clone, Debug, Default, PartialEq, Serialize)]
#[derive(Clone, Debug, Default, PartialEq)]
pub struct UsageSection {
/// The provider reported this window, even if unused and without a reset time.
pub available: bool,
pub percentage: f64,
pub resets_at: Option<SystemTime>,
}

impl Serialize for UsageSection {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
// An absent window carries no figure: external cache readers that only
// look at `percentage` must see "unknown", not 0% used.
let mut map = serializer.serialize_map(Some(3))?;
map.serialize_entry("available", &self.available)?;
map.serialize_entry("percentage", &self.available.then_some(self.percentage))?;
map.serialize_entry("resets_at", &self.resets_at)?;
map.end()
}
}

impl<'de> Deserialize<'de> for UsageSection {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
Expand All @@ -22,17 +37,18 @@ impl<'de> Deserialize<'de> for UsageSection {
#[derive(Deserialize)]
struct StoredSection {
available: Option<bool>,
percentage: f64,
percentage: Option<f64>,
resets_at: Option<SystemTime>,
}
let stored = StoredSection::deserialize(deserializer)?;
let percentage = stored.percentage.unwrap_or(0.0);
Ok(Self {
// Older caches lost the distinction between an idle window and an
// absent one. Preserve evidence of presence until a fresh poll.
available: stored
.available
.unwrap_or(stored.resets_at.is_some() || stored.percentage != 0.0),
percentage: stored.percentage,
.unwrap_or(stored.resets_at.is_some() || percentage != 0.0),
percentage,
resets_at: stored.resets_at,
})
}
Expand Down Expand Up @@ -70,6 +86,9 @@ pub struct UsageData {
/// Additional API quotas, exposed only through opt-in theme bindings.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub limits: Vec<UsageLimit>,
/// Secondary quota pool for providers with multiple distinct allowances (e.g. Antigravity External Models).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub external_pool: Option<Box<UsageData>>,
/// True when this reading was carried over from an earlier poll because
/// the provider failed this cycle. The figures are real, just not current.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
Expand All @@ -90,10 +109,15 @@ pub struct UsageLimit {

impl UsageData {
pub fn sections(&self) -> impl Iterator<Item = &UsageSection> {
let external_sections: Box<dyn Iterator<Item = &UsageSection>> = match &self.external_pool {
Some(pool) => Box::new(pool.sections()),
None => Box::new(std::iter::empty()),
};
[&self.session, &self.weekly]
.into_iter()
.chain(self.monthly.iter())
.chain(self.limits.iter().map(|limit| &limit.usage))
.chain(external_sections)
}
}

Expand Down Expand Up @@ -175,6 +199,11 @@ impl AppUsageData {
self.providers.get(&provider)
}

pub fn antigravity_external(&self) -> Option<&UsageData> {
self.get(ProviderId::Antigravity)
.and_then(|u| u.external_pool.as_deref())
}

pub fn insert(&mut self, provider: ProviderId, usage: UsageData) -> Option<UsageData> {
self.providers.insert(provider, usage)
}
Expand Down Expand Up @@ -297,7 +326,18 @@ impl Serialize for AppUsageData {
{
let mut map = serializer.serialize_map(None)?;
for (provider, usage) in &self.providers {
map.serialize_entry(provider.descriptor().cache_key, usage)?;
if *provider == ProviderId::Antigravity {
map.serialize_entry("antigravity_gemini", usage)?;
if let Some(external) = &usage.external_pool {
map.serialize_entry("antigravity_external", external.as_ref())?;
// Legacy alias for limits.py compatibility (mirrors external pool)
map.serialize_entry("antigravity", external.as_ref())?;
}
// Without an external pool the legacy key is left out rather than
// filled with Gemini figures, so readers never mistake one pool for the other.
} else {
map.serialize_entry(provider.descriptor().cache_key, usage)?;
}
}
if !self.accounts.is_empty() {
map.serialize_entry("accounts", &self.accounts)?;
Expand All @@ -318,6 +358,11 @@ impl<'de> Deserialize<'de> for AppUsageData {
.transpose()
.map_err(serde::de::Error::custom)?
.unwrap_or_default();

let gemini_val = values.remove("antigravity_gemini");
let external_val = values.remove("antigravity_external");
let legacy_agy_val = values.remove("antigravity");

let mut data: Self = values
.into_iter()
.filter_map(|(key, usage)| {
Expand All @@ -327,6 +372,23 @@ impl<'de> Deserialize<'de> for AppUsageData {
.map(|provider| (provider, usage))
})
.collect();

let agy_usage = gemini_val
.and_then(|v| serde_json::from_value::<Option<UsageData>>(v).ok().flatten())
.or_else(|| {
legacy_agy_val
.and_then(|v| serde_json::from_value::<Option<UsageData>>(v).ok().flatten())
});

if let Some(mut agy) = agy_usage {
if let Some(ext) = external_val
.and_then(|v| serde_json::from_value::<Option<UsageData>>(v).ok().flatten())
{
agy.external_pool = Some(Box::new(ext));
}
data.insert(ProviderId::Antigravity, agy);
}

data.accounts = accounts;
Ok(data)
}
Expand Down Expand Up @@ -464,4 +526,76 @@ mod tests {
assert!(decoded.get(ProviderId::Antigravity).is_none());
assert!(decoded.get(ProviderId::OpenCode).is_none());
}

#[test]
fn usage_cache_antigravity_dual_pools_roundtrip_and_legacy_compatibility() {
let mut gemini_usage = UsageData {
session: UsageSection {
available: true,
percentage: 15.0,
resets_at: None,
},
weekly: UsageSection {
available: true,
percentage: 30.0,
resets_at: None,
},
..Default::default()
};
let external_usage = UsageData {
session: UsageSection {
available: true,
percentage: 60.0,
resets_at: None,
},
weekly: UsageSection {
available: true,
percentage: 80.0,
resets_at: None,
},
..Default::default()
};
gemini_usage.external_pool = Some(Box::new(external_usage));

let mut data = AppUsageData::default();
data.insert(ProviderId::Antigravity, gemini_usage);

let json = serde_json::to_value(&data).unwrap();
// Check keys: antigravity_gemini, antigravity_external, and legacy antigravity mirror
assert_eq!(json["antigravity_gemini"]["session"]["percentage"], 15.0);
assert_eq!(json["antigravity_external"]["session"]["percentage"], 60.0);
assert_eq!(json["antigravity"]["session"]["percentage"], 60.0);

// Deserialization round-trip
let decoded: AppUsageData = serde_json::from_value(json).unwrap();
let agy = decoded.get(ProviderId::Antigravity).expect("Antigravity present");
assert_eq!(agy.session.percentage, 15.0);
let ext = decoded.antigravity_external().expect("External pool present");
assert_eq!(ext.session.percentage, 60.0);
assert_eq!(ext.weekly.percentage, 80.0);

// Test section chaining:
let section_percentages: Vec<f64> = agy.sections().map(|s| s.percentage).collect();
assert_eq!(section_percentages, vec![15.0, 30.0, 60.0, 80.0]);
}

#[test]
fn usage_cache_omits_legacy_antigravity_key_without_external_pool() {
let mut data = AppUsageData::default();
data.insert(ProviderId::Antigravity, UsageData::default());
let json = serde_json::to_value(&data).unwrap();
assert!(json.get("antigravity_gemini").is_some());
// `antigravity` means the external pool; Gemini figures must never fill it.
assert!(json.get("antigravity").is_none());
assert!(json.get("antigravity_external").is_none());
}

#[test]
fn absent_window_is_cached_without_a_percentage() {
let section = UsageSection::default();
let json = serde_json::to_value(&section).unwrap();
assert!(json["percentage"].is_null(), "readers must not see 0% for an absent window");
let decoded: UsageSection = serde_json::from_value(json).unwrap();
assert_eq!(decoded, section);
}
}
2 changes: 2 additions & 0 deletions src/native_interop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ pub const TIMER_MOUSE_CLICK: usize = 6;
pub const TIMER_TRAY_HOVER: usize = 7;
pub const TIMER_CLOCK: usize = 8;
pub const TIMER_TRAY_REPOSITION: usize = 9;
pub const TIMER_CROSSFADE_SCHEDULE: usize = 14;
pub const TIMER_CROSSFADE_ANIM: usize = 15;

// Custom messages
pub const WM_APP: u32 = 0x8000;
Expand Down
Loading
Loading