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
@@ -1,3 +1,4 @@
- [2026-10-01] codex windows named by length, one setting per window
- [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
Expand Down
14 changes: 3 additions & 11 deletions gui/assets/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion gui/src/format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ static FIVE_HOUR: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)5 ?h").unw
static WEEKLY_LABEL: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)^(7 day(?: · all models)?)$").unwrap());
static WEEKLY_ID: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^(weekly_all|seven_day|weekly|codex:primary)$").unwrap());
LazyLock::new(|| Regex::new(r"^(weekly_all|seven_day|weekly)$").unwrap());
static SCOPED: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)scoped|fable|opus|sonnet|spark").unwrap());

Expand Down
158 changes: 131 additions & 27 deletions gui/src/tray.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,32 +133,62 @@ fn lines(analytics: &AnalyticsSnapshot) -> Vec<Line> {
lines
}

fn build_menu(lines: &[Line]) -> Result<Menu> {
/// The live menu item behind each line, kept so a poll can patch text in place.
enum Item {
Text(MenuItem),
Account(IconMenuItem),
Fixed,
}

fn build_menu(lines: &[Line]) -> Result<(Menu, Vec<Item>)> {
let menu = Menu::new();
let mut items = Vec::with_capacity(lines.len());
for line in lines {
match line {
Line::Heading(text) | Line::Note(text) => {
menu.append(&MenuItem::new(text, false, None))?
let item = MenuItem::new(text, false, None);
menu.append(&item)?;
items.push(Item::Text(item));
}
Line::Account {
command,
text,
active,
pressure,
} => menu.append(&IconMenuItem::with_id(
command.id(),
text,
true,
Some(pressure_dot(*pressure, *active)?),
None,
))?,
} => {
let item = IconMenuItem::with_id(
command.id(),
text,
true,
Some(pressure_dot(*pressure, *active)?),
None,
);
menu.append(&item)?;
items.push(Item::Account(item));
}
Line::Command(command, text) => {
menu.append(&MenuItem::with_id(command.id(), text, true, None))?
menu.append(&MenuItem::with_id(command.id(), text, true, None))?;
items.push(Item::Fixed);
}
Line::Separator => {
menu.append(&PredefinedMenuItem::separator())?;
items.push(Item::Fixed);
}
Line::Separator => menu.append(&PredefinedMenuItem::separator())?,
}
}
Ok(menu)
Ok((menu, items))
}

/// Whether two lines can share one menu item: same kind, and the same command behind a clickable line.
fn same_slot(current: &Line, next: &Line) -> bool {
match (current, next) {
(Line::Heading(_), Line::Heading(_))
| (Line::Note(_), Line::Note(_))
| (Line::Separator, Line::Separator) => true,
(Line::Account { command: a, .. }, Line::Account { command: b, .. }) => a == b,
(Line::Command(a, _), Line::Command(b, _)) => a == b,
_ => false,
}
}

const DOT_SIZE: u32 = 36;
Expand Down Expand Up @@ -201,51 +231,108 @@ fn pressure_dot(pressure: Pressure, active: bool) -> Result<MenuIcon> {
)?)
}

/// A gauge of three rising bars, drawn as a template image so macOS tints it for the menu bar.
fn icon() -> Result<Icon> {
/// The tokenmaxx mark as a menu-bar template: Rubric's 5×5 grid, a one-cell ring around a 3×3
/// rising staircase cut out of the tile, cell edges snapped to whole pixels so it stays crisp.
fn icon_pixels() -> Vec<u8> {
const SIZE: u32 = 36;
let bars = [(6, 14, 16), (15, 23, 24), (24, 32, 32)];
const EDGES: [u32; 6] = [0, 7, 14, 22, 29, 36];
const GLYPH: [[bool; 3]; 3] = [
[false, false, true],
[false, true, true],
[true, true, true],
];
let radius = 0.78 * SIZE as f32 / 5.;
let cell = |value: u32| {
EDGES
.windows(2)
.position(|edge| value >= edge[0] && value < edge[1])
.unwrap_or(0)
};
let mut pixels = vec![0u8; (SIZE * SIZE * 4) as usize];
for y in 0..SIZE {
for x in 0..SIZE {
let filled = bars.iter().any(|&(left, right, height)| {
x >= left && x < right && y >= SIZE - 2 - height && y < SIZE - 2
});
if filled {
let index = ((y * SIZE + x) * 4) as usize;
pixels[index..index + 4].copy_from_slice(&[0, 0, 0, 255]);
let (column, row) = (cell(x), cell(y));
if (1..=3).contains(&column) && (1..=3).contains(&row) && GLYPH[row - 1][column - 1] {
continue;
}
let (left, top) = (x as f32 + 0.5, y as f32 + 0.5);
let nearest_x = left.clamp(radius, SIZE as f32 - radius);
let nearest_y = top.clamp(radius, SIZE as f32 - radius);
let corner = ((left - nearest_x).powi(2) + (top - nearest_y).powi(2)).sqrt();
let coverage = (radius + 0.5 - corner.max(radius - 0.5)).clamp(0., 1.);
let index = ((y * SIZE + x) * 4) as usize;
pixels[index + 3] = (coverage * 255.) as u8;
}
}
Ok(Icon::from_rgba(pixels, SIZE, SIZE)?)
pixels
}

fn icon() -> Result<Icon> {
Ok(Icon::from_rgba(icon_pixels(), 36, 36)?)
}

pub struct Tray {
icon: TrayIcon,
lines: Vec<Line>,
items: Vec<Item>,
}

impl Tray {
pub fn new(analytics: &AnalyticsSnapshot) -> Result<Self> {
let lines = lines(analytics);
let (menu, items) = build_menu(&lines)?;
let icon = TrayIconBuilder::new()
.with_icon(icon()?)
.with_icon_as_template(true)
.with_tooltip("tokenmaxx")
.with_menu(Box::new(build_menu(&lines)?))
.with_menu(Box::new(menu))
.build()?;
Ok(Self { icon, lines })
Ok(Self { icon, lines, items })
}

/// Patches the open menu in place when only its text changed; swapping in a new menu would
/// dismiss it from under the pointer on every poll.
pub fn update(&mut self, analytics: &AnalyticsSnapshot) {
let next = lines(analytics);
if next == self.lines {
return;
}
if let Ok(menu) = build_menu(&next) {
self.icon.set_menu(Some(Box::new(menu)));
self.lines = next;
let same_shape = next.len() == self.lines.len()
&& self
.lines
.iter()
.zip(&next)
.all(|(current, next)| same_slot(current, next));
if !same_shape {
if let Ok((menu, items)) = build_menu(&next) {
self.icon.set_menu(Some(Box::new(menu)));
self.lines = next;
self.items = items;
}
return;
}
for ((current, line), item) in self.lines.iter().zip(&next).zip(&self.items) {
if current == line {
continue;
}
match (line, item) {
(Line::Heading(text) | Line::Note(text), Item::Text(entry)) => entry.set_text(text),
(
Line::Account {
text,
active,
pressure,
..
},
Item::Account(entry),
) => {
entry.set_text(text);
entry.set_icon(pressure_dot(*pressure, *active).ok());
}
_ => {}
}
}
self.lines = next;
}
}

Expand Down Expand Up @@ -276,4 +363,21 @@ mod tests {
assert_eq!(TrayCommand::parse(&command.id()), Some(command));
}
}

#[test]
fn a_changed_number_patches_the_open_menu_but_a_new_order_rebuilds_it() {
let account = |id: &str, text: &str| Line::Account {
active: false,
command: TrayCommand::Switch(Provider::Openai, id.into()),
pressure: Pressure::Good,
text: text.into(),
};
assert!(same_slot(&account("a", "5h 10%"), &account("a", "5h 64%")));
assert!(same_slot(
&Line::Note("1M/h now".into()),
&Line::Note("2M/h now".into())
));
assert!(!same_slot(&account("a", "5h 10%"), &account("b", "5h 10%")));
assert!(!same_slot(&Line::Note("1M/h".into()), &Line::Separator));
}
}
Loading
Loading