diff --git a/CHANGELOG.md b/CHANGELOG.md index d18521f..a763fd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/gui/assets/icon.svg b/gui/assets/icon.svg index fb8f425..d555f68 100644 --- a/gui/assets/icon.svg +++ b/gui/assets/icon.svg @@ -1,12 +1,4 @@ - - - - - - - - - - - + + + diff --git a/gui/src/format.rs b/gui/src/format.rs index c88c536..1cf6b5e 100644 --- a/gui/src/format.rs +++ b/gui/src/format.rs @@ -161,7 +161,7 @@ static FIVE_HOUR: LazyLock = LazyLock::new(|| Regex::new(r"(?i)5 ?h").unw static WEEKLY_LABEL: LazyLock = LazyLock::new(|| Regex::new(r"(?i)^(7 day(?: · all models)?)$").unwrap()); static WEEKLY_ID: LazyLock = - 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 = LazyLock::new(|| Regex::new(r"(?i)scoped|fable|opus|sonnet|spark").unwrap()); diff --git a/gui/src/tray.rs b/gui/src/tray.rs index 99eb3dd..2ee02f0 100644 --- a/gui/src/tray.rs +++ b/gui/src/tray.rs @@ -133,32 +133,62 @@ fn lines(analytics: &AnalyticsSnapshot) -> Vec { lines } -fn build_menu(lines: &[Line]) -> Result { +/// 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)> { 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; @@ -201,51 +231,108 @@ fn pressure_dot(pressure: Pressure, active: bool) -> Result { )?) } -/// A gauge of three rising bars, drawn as a template image so macOS tints it for the menu bar. -fn icon() -> Result { +/// 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 { 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 { + Ok(Icon::from_rgba(icon_pixels(), 36, 36)?) } pub struct Tray { icon: TrayIcon, lines: Vec, + items: Vec, } impl Tray { pub fn new(analytics: &AnalyticsSnapshot) -> Result { 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; } } @@ -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)); + } } diff --git a/gui/src/views/analytics.rs b/gui/src/views/analytics.rs index 0f88b6e..92e2a65 100644 --- a/gui/src/views/analytics.rs +++ b/gui/src/views/analytics.rs @@ -1,5 +1,8 @@ +use std::cell::Cell; +use std::rc::Rc; + +use chrono::{DateTime, Local, TimeZone as _, Utc}; use gpui_kit::component::StyledExt as _; -use gpui_kit::component::chart::AreaChart; use gpui_kit::component::tab::TabBar; use gpui_kit::component::{ActiveTheme as _, Sizable as _, h_flex, v_flex}; use gpui_kit::prelude::FluentBuilder as _; @@ -11,7 +14,9 @@ use crate::store::{Connection, Store}; use crate::views::{muted, page_header, section_title}; const DEFAULT_TIMEFRAME: usize = 2; -const CHART_POINTS: usize = 48; +const PLOT_HEIGHT: f32 = 220.; +const Y_GUTTER: f32 = 56.; +const HOUR_MS: f64 = 3_600_000.; #[derive(Clone, Copy, PartialEq, Eq)] enum View { @@ -23,6 +28,8 @@ pub struct AnalyticsPage { store: Entity, timeframe: usize, view: View, + hovered: Option, + plot: Rc>>, } impl AnalyticsPage { @@ -32,34 +39,67 @@ impl AnalyticsPage { store, timeframe: DEFAULT_TIMEFRAME, view: View::Chart, + hovered: None, + plot: Rc::new(Cell::new(Bounds::default())), } } } -/// Downsamples by keeping each column's peak, so short bursts survive the resampling. -fn columns(buckets: &[f64], count: usize) -> Vec { - if buckets.len() <= count { - return buckets.to_vec(); +/// A round step that splits `max` into about four gridlines: 1, 2, 2.5 or 5 times a power of ten. +fn nice_step(max: f64) -> f64 { + if max <= 0. { + return 1.; + } + let raw = max / 4.; + let magnitude = 10f64.powf(raw.log10().floor()); + let step = [1., 2., 2.5, 5., 10.] + .into_iter() + .find(|candidate| raw / magnitude <= *candidate) + .unwrap_or(10.); + step * magnitude +} + +/// Clock ticks across `[start, end]`, aligned to local time, about five of them. +fn time_ticks(start_ms: f64, end_ms: f64) -> Vec<(f64, String)> { + const STEPS_MINUTES: [f64; 13] = [ + 5., 10., 15., 30., 60., 120., 180., 240., 360., 720., 1440., 2880., 10080., + ]; + let span_minutes = (end_ms - start_ms) / 60_000.; + let step_minutes = STEPS_MINUTES + .into_iter() + .find(|step| span_minutes / step <= 6.) + .unwrap_or(10080.); + let step_ms = step_minutes * 60_000.; + let offset_ms = Local::now().offset().local_minus_utc() as f64 * 1000.; + let mut tick = ((start_ms + offset_ms) / step_ms).ceil() * step_ms - offset_ms; + let mut ticks = Vec::new(); + while tick <= end_ms { + if let Some(time) = Local.timestamp_millis_opt(tick as i64).single() { + let label = if step_minutes < 60. { + time.format("%-I:%M %p").to_string() + } else if step_minutes < 1440. { + time.format("%-I %p").to_string() + } else { + time.format("%b %-d").to_string() + }; + ticks.push(((tick - start_ms) / (end_ms - start_ms), label)); + } + tick += step_ms; } - (0..count) - .map(|column| { - let low = column * buckets.len() / count; - let high = ((column + 1) * buckets.len() / count).max(low + 1); - buckets[low..high].iter().copied().fold(0.0, f64::max) - }) - .collect() + ticks } -fn ago_label(milliseconds: f64) -> String { - let minutes = (milliseconds / 60_000.0).round(); - if minutes < 1.0 { - "now".into() - } else if minutes < 60.0 { - format!("{minutes}m") - } else if minutes < 48.0 * 60.0 { - format!("{}h", (minutes / 60.0).round()) +fn bucket_span(start: DateTime, end: DateTime, multi_day: bool) -> String { + let time = |moment: DateTime| moment.format("%-I:%M %p").to_string(); + if multi_day { + format!( + "{}, {} – {}", + start.format("%b %-d"), + time(start), + time(end) + ) } else { - format!("{}d", (minutes / 1440.0).round()) + format!("{} – {}", time(start), time(end)) } } @@ -132,35 +172,194 @@ fn card(cx: &App) -> Div { } impl AnalyticsPage { - fn chart(&self, timeframe: &TokenTimeframe, cx: &App) -> impl IntoElement { - let values = columns(&timeframe.buckets, CHART_POINTS); - let span = timeframe.bucket_ms * timeframe.buckets.len() as f64; - let step = span / values.len().max(1) as f64; - let points: Vec<(SharedString, f64)> = values + fn chart(&self, timeframe: &TokenTimeframe, cx: &mut Context) -> impl IntoElement { + let count = timeframe.buckets.len().max(1); + let per_hour = HOUR_MS / timeframe.bucket_ms; + let rates: Vec = timeframe + .buckets .iter() - .enumerate() - .map(|(index, value)| { - let remaining = span - step * (index + 1) as f64; - (SharedString::from(ago_label(remaining)), *value) - }) + .map(|tokens| tokens * per_hour) + .collect(); + let peak = rates.iter().copied().fold(0., f64::max); + let step = nice_step(peak); + let top = step * (peak / step).ceil().max(1.); + let gridlines: Vec = (0..=(top / step).round() as usize) + .map(|index| index as f64 * step) .collect(); - let accent = cx.theme().blue; + let end_ms = Utc::now().timestamp_millis() as f64; + let start_ms = end_ms - timeframe.bucket_ms * count as f64; + let hovered = self.hovered.filter(|index| *index < count); + + let theme = cx.theme(); + let (line, grid, muted_text) = (theme.blue, theme.border, theme.muted_foreground); + let band = theme.foreground.opacity(0.06); + let plot = self.plot.clone(); + let painted = (rates.clone(), gridlines.clone()); + let surface = canvas( + move |bounds, _, _| { + plot.set(bounds); + bounds + }, + move |bounds, _, window, _| { + let (rates, gridlines) = painted; + let width = bounds.size.width.as_f32(); + let height = bounds.size.height.as_f32(); + let x = |index: usize| bounds.origin.x + px(width * index as f32 / count as f32); + let y = |value: f64| bounds.origin.y + px(height - (value / top) as f32 * height); + for value in gridlines { + window.paint_quad(fill( + Bounds::new( + point(bounds.origin.x, y(value) - px(0.5)), + size(bounds.size.width, px(1.)), + ), + grid, + )); + } + if let Some(index) = hovered { + window.paint_quad(fill( + Bounds::from_corners( + point(x(index), bounds.origin.y), + point(x(index + 1), bounds.bottom()), + ), + band, + )); + } + let mut area = PathBuilder::fill(); + let mut edge = PathBuilder::stroke(px(2.)); + area.move_to(point(x(0), y(0.))); + for (index, rate) in rates.iter().enumerate() { + area.line_to(point(x(index), y(*rate))); + area.line_to(point(x(index + 1), y(*rate))); + if index == 0 { + edge.move_to(point(x(0), y(*rate))); + } else { + edge.line_to(point(x(index), y(*rate))); + } + edge.line_to(point(x(index + 1), y(*rate))); + } + area.line_to(point(x(count), y(0.))); + area.close(); + if let Ok(path) = area.build() { + window.paint_path(path, line.opacity(0.12)); + } + if let Ok(path) = edge.build() { + window.paint_path(path, line); + } + }, + ) + .size_full(); + + let readout = hovered.map(|index| { + let bucket_start = start_ms + index as f64 * timeframe.bucket_ms; + let span = match ( + Local.timestamp_millis_opt(bucket_start as i64).single(), + Local + .timestamp_millis_opt((bucket_start + timeframe.bucket_ms) as i64) + .single(), + ) { + (Some(from), Some(to)) => { + bucket_span(from, to, timeframe.bucket_ms * count as f64 > 30. * HOUR_MS) + } + _ => String::new(), + }; + let center = (index as f32 + 0.5) / count as f32; + v_flex() + .absolute() + .top(px(8.)) + .map(|this| { + if center < 0.6 { + this.left(relative(center)).ml(px(12.)) + } else { + this.right(relative(1. - center)).mr(px(12.)) + } + }) + .px_3() + .py_2() + .gap_0p5() + .rounded(cx.theme().radius) + .border_1() + .border_color(cx.theme().border) + .bg(cx.theme().popover) + .shadow_md() + .child( + div() + .text_sm() + .font_semibold() + .child(format!("{}/h", format::compact_number(rates[index]))), + ) + .child(div().text_xs().text_color(muted_text).child(format!( + "{} tokens · {span}", + format::compact_number(timeframe.buckets[index]) + ))) + }); + + let y_labels = div() + .relative() + .flex_shrink_0() + .w(px(Y_GUTTER)) + .h(px(PLOT_HEIGHT)) + .children(gridlines.iter().map(|value| { + div() + .absolute() + .right(px(10.)) + .top(px(PLOT_HEIGHT - (value / top) as f32 * PLOT_HEIGHT - 8.)) + .text_xs() + .text_color(muted_text) + .child(format::compact_number(*value)) + })); + let x_labels = div().relative().h(px(16.)).ml(px(Y_GUTTER)).children( + time_ticks(start_ms, end_ms) + .into_iter() + .filter(|(fraction, _)| *fraction > 0.04 && *fraction < 0.96) + .map(|(fraction, label)| { + div() + .absolute() + .left(relative(fraction as f32)) + .ml(px(-40.)) + .w(px(80.)) + .text_center() + .text_xs() + .text_color(muted_text) + .child(label) + }), + ); + let plot_area = div() + .id("throughput") + .relative() + .flex_1() + .h(px(PLOT_HEIGHT)) + .child(surface) + .children(readout) + .on_mouse_move(cx.listener(move |this, event: &MouseMoveEvent, _, cx| { + let bounds = this.plot.get(); + let width = bounds.size.width.as_f32(); + if width <= 0. || !bounds.contains(&event.position) { + return; + } + let fraction = (event.position.x - bounds.origin.x).as_f32() / width; + let index = Some(((fraction * count as f32) as usize).min(count - 1)); + if this.hovered != index { + this.hovered = index; + cx.notify(); + } + })) + .on_hover(cx.listener(|this, hovering: &bool, _, cx| { + if !*hovering && this.hovered.take().is_some() { + cx.notify(); + } + })); + card(cx) .p_4() - .gap_2() - .child(section_title("Token throughput · all accounts", cx)) + .gap_3() .child( - div().h_64().child( - 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) - .id("throughput"), - ), + h_flex() + .justify_between() + .child(section_title("Token throughput · all accounts", cx)) + .child(muted("tokens per hour", cx)), ) + .child(h_flex().items_start().child(y_labels).child(plot_area)) + .child(x_labels) } fn pricing(&self, timeframe: &TokenTimeframe, cx: &App) -> impl IntoElement { diff --git a/package.json b/package.json index be8322c..3df348a 100644 --- a/package.json +++ b/package.json @@ -63,5 +63,5 @@ "post-commit": "bun x @rubriclab/package post-commit" }, "type": "module", - "version": "0.0.78" + "version": "0.0.79" } diff --git a/src/codex.test.ts b/src/codex.test.ts index 45a4ba2..56d5cb0 100644 --- a/src/codex.test.ts +++ b/src/codex.test.ts @@ -94,6 +94,7 @@ describe('codex reset credits on the wire', () => { vault: memoryVault({ [reference]: credential }) }) expect(result.usage.provider).toBe('openai') + expect(result.usage.windows.map(window => window.id)).toEqual(['codex:10080m']) expect(result.usage.provider === 'openai' && result.usage.resetCredits).toEqual({ applicable: 0, available: 3 diff --git a/src/codex.ts b/src/codex.ts index c916399..7d23b41 100644 --- a/src/codex.ts +++ b/src/codex.ts @@ -407,6 +407,11 @@ function toWindow( } } +function windowId(prefix: string, slot: string, window: z.infer): string { + const minutes = Math.round((window.limit_window_seconds ?? 0) / 60) + return minutes > 0 ? `${prefix}:${minutes}m` : `${prefix}:${slot}` +} + function appendLimitWindows( windows: UsageWindow[], prefix: string, @@ -414,10 +419,12 @@ function appendLimitWindows( details: z.infer | null | undefined ): void { if (details?.primary_window != null) { - windows.push(toWindow(`${prefix}:primary`, label, 'primary', details.primary_window)) + const window = details.primary_window + windows.push(toWindow(windowId(prefix, 'primary', window), label, 'primary', window)) } if (details?.secondary_window != null) { - windows.push(toWindow(`${prefix}:secondary`, label, 'secondary', details.secondary_window)) + const window = details.secondary_window + windows.push(toWindow(windowId(prefix, 'secondary', window), label, 'secondary', window)) } } diff --git a/src/ratelimit.test.ts b/src/ratelimit.test.ts index 9f9ece3..1ce8083 100644 --- a/src/ratelimit.test.ts +++ b/src/ratelimit.test.ts @@ -60,16 +60,15 @@ describe('observeRateLimitHeaders', () => { const observation = observeRateLimitHeaders('openai', headers, 200) expect(observation?.limited).toBe(false) const byId = new Map(observation?.windows.map(window => [window.id, window])) - expect(byId.get('codex:primary')).toEqual({ - id: 'codex:primary', + expect(byId.get('codex:10080m')).toEqual({ + id: 'codex:10080m', kind: 'hard', label: '7 day', resetAt: new Date(1784780264 * 1000).toISOString(), usedPercent: 18 }) - expect(byId.get('codex_bengalfox:primary')?.label).toBe('GPT-5.3-Codex-Spark · 7 day') - expect(byId.has('codex:secondary')).toBe(false) - expect(byId.has('codex_bengalfox:secondary')).toBe(false) + expect(byId.get('codex_bengalfox:10080m')?.label).toBe('GPT-5.3-Codex-Spark · 7 day') + expect(observation?.windows).toHaveLength(2) }) test('codex 429 with no headers still reports limited', () => { diff --git a/src/ratelimit.ts b/src/ratelimit.ts index 2b0c879..3e96c1e 100644 --- a/src/ratelimit.ts +++ b/src/ratelimit.ts @@ -81,7 +81,7 @@ function codexObservation(headers: Headers, status: number): RateLimitObservatio if (!Number.isFinite(minutes) || minutes <= 0) { return } - const id = `${feature}:${slot}` + const id = `${feature}:${Math.round(minutes)}m` if (seen.has(id)) { return } diff --git a/src/tui/usage-windows.ts b/src/tui/usage-windows.ts index 1cb2c89..bb51cc4 100644 --- a/src/tui/usage-windows.ts +++ b/src/tui/usage-windows.ts @@ -9,7 +9,7 @@ function usageWindowPriority(window: UsageWindow): number { case isFiveHourUsageWindow(window): return 0 case /^(7 day(?: · all models)?)$/i.test(window.label): - case /^(weekly_all|seven_day|weekly|codex:primary)$/.test(window.id): + case /^(weekly_all|seven_day|weekly)$/.test(window.id): return 1 case /^(weekly_scoped|codex_)/.test(window.id): return 2