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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
17 changes: 17 additions & 0 deletions gui/src/format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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<String> = 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<f64> {
account_windows(snapshot, account)
.into_iter()
Expand Down
57 changes: 53 additions & 4 deletions gui/src/tray.rs
Original file line number Diff line number Diff line change
@@ -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)]
Expand Down Expand Up @@ -52,6 +54,7 @@ enum Line {
command: TrayCommand,
text: String,
active: bool,
pressure: Pressure,
},
Note(String),
Command(TrayCommand, &'static str),
Expand Down Expand Up @@ -97,6 +100,11 @@ fn lines(analytics: &AnalyticsSnapshot) -> Vec<Line> {
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);
Expand Down Expand Up @@ -136,11 +144,12 @@ fn build_menu(lines: &[Line]) -> Result<Menu> {
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) => {
Expand All @@ -152,6 +161,46 @@ fn build_menu(lines: &[Line]) -> Result<Menu> {
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<u8> {
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<MenuIcon> {
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<Icon> {
const SIZE: u32 = 36;
Expand Down
96 changes: 78 additions & 18 deletions gui/src/views/accounts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;
Expand Down Expand Up @@ -36,7 +36,7 @@ fn window_meter(window: &UsageWindow, now: DateTime<Utc>, 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(
Expand All @@ -62,6 +62,26 @@ fn window_meter(window: &UsageWindow, now: DateTime<Utc>, 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<Div> {
let extra = snapshot.usage(&account.id)?.extra_usage.as_ref()?;
if !extra.enabled || account.auth == AuthKind::ApiKey {
Expand Down Expand Up @@ -94,6 +114,9 @@ impl AccountsPage {
&self,
snapshot: &DashboardSnapshot,
account: &Account,
columns: &[String],
extra_column: bool,
width: Pixels,
now: DateTime<Utc>,
busy: bool,
window: &mut Window,
Expand Down Expand Up @@ -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()
};
Expand All @@ -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(),
Expand All @@ -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()
};

Expand Down Expand Up @@ -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()
Expand All @@ -322,6 +358,7 @@ impl AccountsPage {
fn provider_section(
&self,
provider: Provider,
width: Pixels,
window: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement {
Expand All @@ -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 {
Expand All @@ -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<AnyElement> = accounts
.iter()
.enumerate()
Expand All @@ -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();
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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))
}
})
}
}
7 changes: 5 additions & 2 deletions gui/src/views/analytics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -224,6 +225,7 @@ impl AnalyticsPage {
impl Render for AnalyticsPage {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> 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)
Expand Down Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,5 +63,5 @@
"post-commit": "bun x @rubriclab/package post-commit"
},
"type": "module",
"version": "0.0.77"
"version": "0.0.78"
}
Loading