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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Added per-account COSMIC panel visibility, with marked accounts displayed
side-by-side as compact panel cells.
- Added Grok subscription usage tracking with browser OAuth, explicit Grok CLI
credential import/restore, managed accounts, and host Active matching.
- Added Z.AI Coding Plan usage tracking with managed API keys, five-hour, weekly,
Expand Down
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ YapCap lives in your COSMIC panel and shows how much of your AI coding quota you
- **Kimi for Coding** — API key usage tracking with weekly and rate-limit windows
- **OpenCode Go** — API key usage tracking with 5-hour, weekly, and monthly windows
- **Grok** — subscription usage shown as a weekly window + available prepaid credits
- 👥 **Multi-account support** — add, switch, and remove accounts per provider. The popup pages through stored accounts one at a time, while the panel remains fixed-width for the active account.
- 👥 **Multi-account support** — add, switch, and remove accounts per provider. The popup pages through stored accounts one at a time, while accounts marked for panel visibility can appear side-by-side as compact panel cells.
- 🔎 **Automatic discovery** — detected providers appear automatically, provider availability updates live, and an empty setup opens Manage providers. Gemini remains opt-in and must be enabled manually.
- 🔐 **In-app login** — guided browser login for Codex, Claude, Antigravity, Gemini, Copilot, and Grok; API-key forms for Minimax, Z.AI, Kimi, and OpenCode Go; Cursor scans the local IDE state.
- 🔑 **OpenCode integration** — compatible keys can optionally prefill Minimax, Z.AI, Kimi, and OpenCode Go forms; Codex and Copilot offer explicit OAuth imports. Credentials are copied only after confirmation and are never synchronized with OpenCode.
Expand Down Expand Up @@ -183,9 +183,17 @@ just install
Each provider supports multiple accounts. Select its popup tab and open **Manage accounts** on the account card, or use the setup action when no account has been added yet.

- **Add account** — starts Codex, Claude, Antigravity, Gemini, or Grok browser OAuth, GitHub Copilot device login, Minimax/Z.AI/Kimi/OpenCode Go API-key entry, or Cursor IDE scanning. Claude asks you to paste the browser's authorization code back into YapCap. Grok also offers explicit import from the Grok CLI.
- **Switch account** — select an account row or use the account card's arrows; the panel and popup follow that selection. This does not switch the host tool's account or its Active badge.
- **Switch account** — select an account row or use the account card's arrows to change the active account shown in the popup. This does not switch the host tool's account or its Active badge.
- **Remove account** — deletes only YapCap's copy of the credentials. Provider accounts and host app configs are never touched.

To show accounts in the COSMIC panel, open **Settings → Accounts** and enable
**Show this account in the panel** for each account you want to display. Each
marked account appears as a compact cell beside the others. Panel visibility
is separate from the active account radio selection: the radio selection
controls the active account and popup, while the per-account toggle controls
which accounts appear in the panel. If no accounts are selected, the panel
shows the YapCap app icon.

Codex, Claude, Cursor, Antigravity, and Gemini keep at most one account per provider identity. Copilot keeps at most one account per GitHub numeric user id and displays the current GitHub username. Minimax, Z.AI, Kimi, and OpenCode Go use unique user-provided labels and reject duplicate API keys.

### Grok
Expand Down
1 change: 1 addition & 0 deletions i18n/en/yapcap.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ account-add-another = Add another
account-cancel = Cancel
account-current-tooltip = Current account
account-delete-tooltip = Delete account
account-panel-flag-tooltip = Show this account in the panel
account-dismiss = Dismiss
account-select = Select
account-selected = Selected
Expand Down
1 change: 1 addition & 0 deletions i18n/pl/yapcap.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ account-add-another = Dodaj jeszcze jedno
account-cancel = Anuluj
account-current-tooltip = Obecne konto
account-delete-tooltip = Usuń konto
account-panel-flag-tooltip = Pokazuj to konto w panelu
account-dismiss = Odrzuć
account-select = Wybierz
account-selected = Wybrane
Expand Down
96 changes: 96 additions & 0 deletions src/app/applet/cells.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
use super::{
AppletBarLayout, applet_bar_layout, applet_button_size, applet_fallback_button_size,
applet_fallback_indicator, applet_indicator, applet_paddings,
};
use crate::app::{
APPLET_CELL_SPACING, Alignment, AppState, Config, Element, Message, PanelIconStyle, ProviderId,
row,
};
use crate::model::ProviderAccountRuntimeState;

pub(in crate::app) struct PanelCell<'a> {
pub account: &'a ProviderAccountRuntimeState,
pub layout: AppletBarLayout,
}

pub(in crate::app) fn panel_cells<'a>(state: &'a AppState, config: &Config) -> Vec<PanelCell<'a>> {
let now = chrono::Utc::now();
let mut cells = Vec::new();
for provider in ProviderId::ALL {
let Some(runtime) = state.provider(provider).filter(|runtime| runtime.enabled) else {
continue;
};
let flagged = config.panel_account_ids(provider);
for account in state.accounts_for(provider) {
if !flagged.contains(&account.account_id) {
continue;
}
let snapshot = account
.snapshot
.as_ref()
.or(runtime.legacy_display_snapshot.as_ref());
cells.push(PanelCell {
account,
layout: applet_bar_layout(
snapshot.and_then(|snapshot| snapshot.applet_windows()),
now,
config.usage_amount_format,
),
});
}
}
cells
}

pub(in crate::app) fn panel_indicator<'a>(
state: &AppState,
config: &Config,
core: &cosmic::Core,
) -> Element<'a, Message> {
let cells = panel_cells(state, config);
let indicator = |cell: &PanelCell<'_>| {
applet_indicator(
cell.account.provider,
cell.layout,
config.panel_icon_style,
core,
)
};
match cells.as_slice() {
[] => applet_fallback_indicator(core),
[cell] => indicator(cell),
_ => row(cells.iter().map(indicator))
.spacing(APPLET_CELL_SPACING)
.align_y(Alignment::Center)
.into(),
}
}

pub(in crate::app) fn panel_button_size(
core: &cosmic::Core,
state: &AppState,
config: &Config,
) -> (f32, f32) {
panel_cells_size(
core,
config.panel_icon_style,
panel_cells(state, config).len(),
)
}

pub(super) fn panel_cells_size(
core: &cosmic::Core,
style: PanelIconStyle,
count: usize,
) -> (f32, f32) {
if count == 0 {
return applet_fallback_button_size(core);
}
let (single_width, height) = applet_button_size(core, style);
let (horizontal_padding, _) = applet_paddings(core);
let cell_width = single_width - f32::from(2 * horizontal_padding);
let width = (1..count).fold(single_width, |width, _| {
width + cell_width + APPLET_CELL_SPACING
});
(width, height)
}
99 changes: 30 additions & 69 deletions src/app/applet.rs → src/app/applet/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
mod cells;

#[cfg(test)]
pub(super) use cells::panel_cells;
pub(super) use cells::{panel_button_size, panel_indicator};

use super::provider_assets::app_icon_handle;
use super::{
APPLET_BAR_WIDTH_HEIGHT_MULTIPLIER, APPLET_ICON_GAP, APPLET_PERCENT_CELL_HORIZONTAL_PAD,
Expand Down Expand Up @@ -55,15 +61,15 @@ pub(crate) fn applet_settings() -> cosmic::app::Settings {
})
.unwrap_or_default();
let detection = crate::detection::startup_snapshot(crate::config::host_user_home_dir());
let no_enabled_provider_has_selected_accounts = ProviderId::ALL.iter().all(|&p| {
!crate::provider_enablement::provider_enabled(&config, &detection, p)
|| config.selected_account_ids(p).is_empty()
});
let (width, height) = if no_enabled_provider_has_selected_accounts {
applet_fallback_button_size(&preview_core)
} else {
applet_button_size(&preview_core, config.panel_icon_style)
};
let cell_count = ProviderId::ALL
.into_iter()
.filter(|&provider| {
crate::provider_enablement::provider_enabled(&config, &detection, provider)
})
.map(|provider| config.panel_account_ids(provider).len())
.sum();
let (width, height) =
cells::panel_cells_size(&preview_core, config.panel_icon_style, cell_count);

cosmic::app::Settings::default()
.size(Size::new(width, height))
Expand All @@ -80,38 +86,34 @@ pub(crate) fn applet_settings() -> cosmic::app::Settings {
.transparent(true)
}

pub(super) fn applet_indicator<'a>(
state: &AppState,
selected_provider: ProviderId,
fn applet_indicator<'a>(
provider: ProviderId,
layout: AppletBarLayout,
style: PanelIconStyle,
usage_amount_format: UsageAmountFormat,
core: &cosmic::Core,
) -> Element<'a, Message> {
let (suggested_w, suggested_h) = core.applet.suggested_size(false);
let compact_px = suggested_w.min(suggested_h);
let logo_size_px = compact_px.saturating_sub(8).max(11);
let logo_size = f32::from(logo_size_px);
let bar_width = applet_bar_width(suggested_w, suggested_h);
let layout = selected_provider_bar_layout(state, selected_provider, usage_amount_format);
let bars = applet_bar_column(layout, bar_width);
let percent = account_percent(layout);

match style {
PanelIconStyle::LogoAndBars => row![
provider_logo(selected_provider, logo_size_px, logo_size),
bars,
]
.spacing(6)
.align_y(Alignment::Center)
.into(),
PanelIconStyle::LogoAndBars => {
row![provider_logo(provider, logo_size_px, logo_size), bars,]
.spacing(6)
.align_y(Alignment::Center)
.into()
}
PanelIconStyle::BarsOnly => bars,
PanelIconStyle::LogoAndPercent => row![
provider_logo(selected_provider, logo_size_px, logo_size),
percent,
]
.spacing(6)
.align_y(Alignment::Center)
.into(),
PanelIconStyle::LogoAndPercent => {
row![provider_logo(provider, logo_size_px, logo_size), percent,]
.spacing(6)
.align_y(Alignment::Center)
.into()
}
PanelIconStyle::PercentOnly => percent,
}
}
Expand All @@ -128,14 +130,6 @@ pub(super) fn provider_logo<'a>(
.into()
}

pub(super) fn panel_fallback_active(state: &AppState) -> bool {
!state.provider_accounts.iter().any(|account| {
state
.provider(account.provider)
.is_some_and(|provider| provider.enabled)
})
}

pub(super) fn applet_fallback_indicator<'a>(core: &cosmic::Core) -> Element<'a, Message> {
let icon_px = applet_fallback_icon_px(core);
let icon_size = f32::from(icon_px);
Expand All @@ -160,18 +154,6 @@ fn applet_fallback_icon_px(core: &cosmic::Core) -> u16 {
suggested_w.min(suggested_h)
}

pub(super) fn panel_button_size(
core: &cosmic::Core,
state: &AppState,
style: PanelIconStyle,
) -> (f32, f32) {
if panel_fallback_active(state) {
applet_fallback_button_size(core)
} else {
applet_button_size(core, style)
}
}

pub(super) fn applet_button<'a>(
core: &cosmic::Core,
(width, height): (f32, f32),
Expand Down Expand Up @@ -269,27 +251,6 @@ fn account_percent(layout: AppletBarLayout) -> Element<'static, Message> {
.into()
}

pub(super) fn selected_provider_bar_layout(
state: &AppState,
selected_provider: ProviderId,
usage_amount_format: UsageAmountFormat,
) -> AppletBarLayout {
let now = chrono::Utc::now();
let snapshot = state
.active_account(selected_provider)
.and_then(|account| account.snapshot.as_ref())
.or_else(|| {
state
.provider(selected_provider)
.and_then(|provider| provider.legacy_display_snapshot.as_ref())
});
applet_bar_layout(
snapshot.and_then(|snapshot| snapshot.applet_windows()),
now,
usage_amount_format,
)
}

pub(super) fn applet_bar_layout(
windows: Option<AppletWindows<'_>>,
now: chrono::DateTime<chrono::Utc>,
Expand Down
2 changes: 2 additions & 0 deletions src/app/login/login_flow_tests/support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ pub(super) fn test_app() -> AppModel {
lock_path,
},
refresh_owner: None,
refresh_batches: Vec::new(),
next_refresh_batch_token: 1,
codex_login: None,
codex_login_handle: None,
claude_login: None,
Expand Down
Loading
Loading