diff --git a/CHANGELOG.md b/CHANGELOG.md index e5ec28d..d18521f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ +- [2026-10-01] mac app polish - [2026-09-30] proxy refuses web pages - [2026-09-30] a native macOS app for tokenmaxx, with a menu bar item - [2026-09-30] macos login startup and uninstall all diff --git a/gui/src/format.rs b/gui/src/format.rs index 606cb61..c88c536 100644 --- a/gui/src/format.rs +++ b/gui/src/format.rs @@ -204,6 +204,23 @@ pub fn account_windows<'a>( .unwrap_or_default() } +/// The window columns every row of a provider shares, so the same window lines up down the list. +pub fn window_columns(snapshot: &DashboardSnapshot, provider: Provider) -> Vec { + let mut windows: Vec<&UsageWindow> = ordered_accounts(snapshot, provider) + .into_iter() + .flat_map(|account| account_windows(snapshot, account)) + .collect(); + windows.sort_by_key(|window| window_priority(window)); + let mut columns: Vec = Vec::new(); + for window in windows { + let column = short_window(&window.label); + if !columns.contains(&column) { + columns.push(column); + } + } + columns +} + pub fn fullest_window(snapshot: &DashboardSnapshot, account: &Account) -> Option { account_windows(snapshot, account) .into_iter() diff --git a/gui/src/tray.rs b/gui/src/tray.rs index 49c7058..99eb3dd 100644 --- a/gui/src/tray.rs +++ b/gui/src/tray.rs @@ -1,8 +1,10 @@ use anyhow::Result; -use tray_icon::menu::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem}; +use tray_icon::menu::{ + Icon as MenuIcon, IconMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, +}; use tray_icon::{Icon, TrayIcon, TrayIconBuilder}; -use crate::format; +use crate::format::{self, Pressure}; use crate::model::{AnalyticsSnapshot, Provider}; #[derive(Clone, Debug, PartialEq, Eq)] @@ -52,6 +54,7 @@ enum Line { command: TrayCommand, text: String, active: bool, + pressure: Pressure, }, Note(String), Command(TrayCommand, &'static str), @@ -97,6 +100,11 @@ fn lines(analytics: &AnalyticsSnapshot) -> Vec { command: TrayCommand::Switch(provider, account.id.clone()), text, active: active.as_deref() == Some(account.id.as_str()), + pressure: if account.health.needs_login() { + Pressure::Bad + } else { + format::pressure(format::fullest_window(snapshot, account)) + }, }); } lines.push(Line::Separator); @@ -136,11 +144,12 @@ fn build_menu(lines: &[Line]) -> Result { command, text, active, - } => menu.append(&CheckMenuItem::with_id( + pressure, + } => menu.append(&IconMenuItem::with_id( command.id(), text, true, - *active, + Some(pressure_dot(*pressure, *active)?), None, ))?, Line::Command(command, text) => { @@ -152,6 +161,46 @@ fn build_menu(lines: &[Line]) -> Result { Ok(menu) } +const DOT_SIZE: u32 = 36; + +/// An account's pressure as a colored dot, filled for the active account and a ring otherwise, +/// the same ● / ○ the terminal dashboard uses. +fn dot_pixels(pressure: Pressure, active: bool) -> Vec { + const RADIUS: f32 = 8.5; + const RING: f32 = 2.5; + let [red, green, blue] = match pressure { + Pressure::Good => [52, 199, 89], + Pressure::Warn => [255, 159, 10], + Pressure::Bad => [255, 59, 48], + Pressure::Unknown => [142, 142, 147], + }; + let center = DOT_SIZE as f32 / 2.; + let mut pixels = vec![0u8; (DOT_SIZE * DOT_SIZE * 4) as usize]; + for y in 0..DOT_SIZE { + for x in 0..DOT_SIZE { + let distance = + ((x as f32 + 0.5 - center).powi(2) + (y as f32 + 0.5 - center).powi(2)).sqrt(); + let outside = (RADIUS + 0.5 - distance).clamp(0., 1.); + let coverage = if active { + outside + } else { + outside.min((distance - (RADIUS - RING) + 0.5).clamp(0., 1.)) + }; + let index = ((y * DOT_SIZE + x) * 4) as usize; + pixels[index..index + 4].copy_from_slice(&[red, green, blue, (coverage * 255.) as u8]); + } + } + pixels +} + +fn pressure_dot(pressure: Pressure, active: bool) -> Result { + Ok(MenuIcon::from_rgba( + dot_pixels(pressure, active), + DOT_SIZE, + DOT_SIZE, + )?) +} + /// A gauge of three rising bars, drawn as a template image so macOS tints it for the menu bar. fn icon() -> Result { const SIZE: u32 = 36; diff --git a/gui/src/views/accounts.rs b/gui/src/views/accounts.rs index ae902e3..fabb2ed 100644 --- a/gui/src/views/accounts.rs +++ b/gui/src/views/accounts.rs @@ -4,7 +4,7 @@ use gpui_kit::component::button::{Button, ButtonVariants as _}; use gpui_kit::component::menu::{DropdownMenu as _, PopupMenuItem}; use gpui_kit::component::progress::Progress; use gpui_kit::component::tag::Tag; -use gpui_kit::component::{ActiveTheme as _, Icon, Sizable as _, h_flex, v_flex}; +use gpui_kit::component::{ActiveTheme as _, ColorName, Icon, Sizable as _, h_flex, v_flex}; use gpui_kit::component::{Disableable as _, StyledExt as _}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; @@ -36,7 +36,7 @@ fn window_meter(window: &UsageWindow, now: DateTime, cx: &App) -> Div { .map(|reset| format!("resets in {reset}")) .unwrap_or_default(); v_flex() - .w_32() + .w(px(112.)) .flex_shrink_0() .gap_1() .child( @@ -62,6 +62,26 @@ fn window_meter(window: &UsageWindow, now: DateTime, cx: &App) -> Div { .child(muted(reset, cx)) } +fn meters_width(windows: usize, extra: bool) -> Pixels { + let windows = windows.clamp(1, WINDOWS_PER_ROW) as f32; + let extra = if extra { 96. + 16. } else { 0. }; + px(windows * 112. + (windows - 1.) * 16. + extra) +} + +fn has_extra_column(snapshot: &DashboardSnapshot, provider: Provider, cx: &App) -> bool { + format::ordered_accounts(snapshot, provider) + .iter() + .any(|account| extra_usage_cell(snapshot, account, cx).is_some()) +} + +/// Width a provider's meters need: its window columns plus the extra-usage column when any account has one. +fn provider_meters_width(snapshot: &DashboardSnapshot, provider: Provider, cx: &App) -> Pixels { + meters_width( + format::window_columns(snapshot, provider).len(), + has_extra_column(snapshot, provider, cx), + ) +} + fn extra_usage_cell(snapshot: &DashboardSnapshot, account: &Account, cx: &App) -> Option
{ let extra = snapshot.usage(&account.id)?.extra_usage.as_ref()?; if !extra.enabled || account.auth == AuthKind::ApiKey { @@ -94,6 +114,9 @@ impl AccountsPage { &self, snapshot: &DashboardSnapshot, account: &Account, + columns: &[String], + extra_column: bool, + width: Pixels, now: DateTime, busy: bool, window: &mut Window, @@ -125,7 +148,7 @@ impl AccountsPage { } if let Some(credits) = credits { let tag = if credits.applicable > 0 { - Tag::success() + Tag::color(ColorName::Green) } else { Tag::secondary() }; @@ -141,15 +164,15 @@ impl AccountsPage { } if let Some(badge) = format::health_badge(account.health) { let tag = match badge.pressure { - Pressure::Bad => Tag::danger(), - Pressure::Warn => Tag::warning(), + Pressure::Bad => Tag::color(ColorName::Red), + Pressure::Warn => Tag::color(ColorName::Amber), _ => Tag::secondary(), }; details.push(tag.small().child(badge.text).into_any_element()); } if just_switched { details.push( - Tag::info() + Tag::color(ColorName::Blue) .small() .child("Just switched") .into_any_element(), @@ -162,22 +185,36 @@ impl AccountsPage { .and_then(|usage| usage.measured_spend_usd) .unwrap_or_default(); v_flex() - .w_32() + .w(width) + .flex_shrink_0() .gap_1() .child(muted("Spend · 31 days", cx)) .child(div().text_sm().child(format::money_usd(spend))) .into_any_element() } else if windows.is_empty() { - muted("No limits reported yet", cx).into_any_element() + div() + .w(width) + .flex_shrink_0() + .child(muted("No limits reported yet", cx)) + .into_any_element() } else { h_flex() .gap_4() - .children( + .w(width) + .flex_shrink_0() + .children(columns.iter().take(WINDOWS_PER_ROW).map(|column| { windows .iter() - .take(WINDOWS_PER_ROW) - .map(|usage_window| window_meter(usage_window, now, cx)), - ) + .find(|usage_window| format::short_window(&usage_window.label) == *column) + .map(|usage_window| window_meter(usage_window, now, cx)) + .unwrap_or_else(|| div().w(px(112.)).flex_shrink_0()) + })) + .when(extra_column, |this| { + this.child( + extra_usage_cell(snapshot, account, cx) + .unwrap_or_else(|| div().w_24().flex_shrink_0()), + ) + }) .into_any_element() }; @@ -307,7 +344,6 @@ impl AccountsPage { .child(h_flex().gap_1().flex_wrap().children(details)), ) .child(meters) - .children(extra_usage_cell(snapshot, account, cx)) .child( h_flex() .gap_1() @@ -322,6 +358,7 @@ impl AccountsPage { fn provider_section( &self, provider: Provider, + width: Pixels, window: &mut Window, cx: &mut Context, ) -> impl IntoElement { @@ -334,9 +371,9 @@ impl AccountsPage { let policy = snapshot.state(provider).map(|state| state.policy.clone()); let status = if !routed { - Tag::warning().small().child("Off") + Tag::color(ColorName::Amber).small().child("Off") } else if let Some(policy) = policy.as_ref().filter(|policy| policy.enabled) { - Tag::success() + Tag::color(ColorName::Green) .small() .child(format!("Auto-switch at {}%", policy.threshold_percent)) } else { @@ -354,6 +391,8 @@ impl AccountsPage { }); let accounts = format::ordered_accounts(&snapshot, provider); + let columns = format::window_columns(&snapshot, provider); + let extra_column = has_extra_column(&snapshot, provider, cx); let rows: Vec = accounts .iter() .enumerate() @@ -362,7 +401,17 @@ impl AccountsPage { .when(index > 0, |this| { this.border_t_1().border_color(cx.theme().border) }) - .child(self.account_row(&snapshot, account, now, busy, window, cx)) + .child(self.account_row( + &snapshot, + account, + &columns, + extra_column, + width, + now, + busy, + window, + cx, + )) .into_any_element() }) .collect(); @@ -453,6 +502,11 @@ impl Render for AccountsPage { let busy = store.is_busy(); let connection = store.connection.clone(); let refresh_store = self.store.clone(); + let connecting = store.connection == Connection::Connecting; + let width = Provider::ALL + .into_iter() + .map(|provider| provider_meters_width(&store.analytics.snapshot, provider, cx)) + .fold(px(0.), Pixels::max); v_flex() .gap_6() @@ -483,7 +537,13 @@ impl Render for AccountsPage { }, |this, message| this.child(muted(format!("Not connected: {message}"), cx)), ) - .child(self.provider_section(Provider::Openai, window, cx)) - .child(self.provider_section(Provider::Anthropic, window, cx)) + .map(|this| { + if connecting { + this.child(muted("Connecting to tokenmaxx…", cx)) + } else { + this.child(self.provider_section(Provider::Openai, width, window, cx)) + .child(self.provider_section(Provider::Anthropic, width, window, cx)) + } + }) } } diff --git a/gui/src/views/analytics.rs b/gui/src/views/analytics.rs index c087641..0f88b6e 100644 --- a/gui/src/views/analytics.rs +++ b/gui/src/views/analytics.rs @@ -7,7 +7,7 @@ use gpui_kit::*; use crate::format; use crate::model::{TIMEFRAMES, TokenBreakdown, TokenTimeframe}; -use crate::store::Store; +use crate::store::{Connection, Store}; use crate::views::{muted, page_header, section_title}; const DEFAULT_TIMEFRAME: usize = 2; @@ -144,7 +144,7 @@ impl AnalyticsPage { (SharedString::from(ago_label(remaining)), *value) }) .collect(); - let accent = cx.theme().primary; + let accent = cx.theme().blue; card(cx) .p_4() .gap_2() @@ -154,6 +154,7 @@ impl AnalyticsPage { AreaChart::new(points) .x(|point: &(SharedString, f64)| point.0.clone()) .y(|point: &(SharedString, f64)| point.1) + .linear() .stroke(accent) .fill(accent.opacity(0.2)) .tick_margin(CHART_POINTS / 6) @@ -224,6 +225,7 @@ impl AnalyticsPage { impl Render for AnalyticsPage { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let key = TIMEFRAMES[self.timeframe]; + let connecting = self.store.read(cx).connection == Connection::Connecting; let analytics = self.store.read(cx).analytics.clone(); let timeframe = analytics .timeframe(key) @@ -265,6 +267,7 @@ impl Render for AnalyticsPage { h_flex().gap_3().child(views).child(ranges), )) .map(|this| match &timeframe { + None if connecting => this.child(muted("Connecting to tokenmaxx…", cx)), None => this.child( card(cx) .p_6() diff --git a/package.json b/package.json index 6ab9ba3..be8322c 100644 --- a/package.json +++ b/package.json @@ -63,5 +63,5 @@ "post-commit": "bun x @rubriclab/package post-commit" }, "type": "module", - "version": "0.0.77" + "version": "0.0.78" }