Vectrum
Sign In

Merge the usage bar: plan limits from Claude or Codex, context and tok/s under the composer

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JjohannSocommitted 2 parents 3a7cb6c 7384f67commitf693a4a

Showing 8 changed files with 611 additions and 3 deletions.

Merge commit — showing changes against the first parent.

+35−1src/session.rsView File
@@ -910,6 +910,11 @@ pub struct Session {
910910 /// Tokens in the context window as of the last response.
911911 pub context_tokens: Option<u64>,
912912 pub context_window: Option<u64>,
913+ /// How fast the last response came out, in output tokens a second.
914+ pub tokens_per_sec: Option<f64>,
915+ /// When the response being streamed sent its first token, and the
916+ /// output tokens it has reported so far.
917+ stream_clock: Option<(Instant, u64)>,
913918 /// Tasks running in the background, oldest first.
914919 pub background_tasks: Vec<BackgroundTask>,
915920 /// When each task the CLI reported started, and the tool that started it.
@@ -1061,6 +1066,8 @@ impl Session {
10611066 worktree_setup: None,
10621067 context_tokens: None,
10631068 context_window: None,
1069+ tokens_per_sec: None,
1070+ stream_clock: None,
10641071 background_tasks: Vec::new(),
10651072 task_origins: HashMap::new(),
10661073 expanded_turns: HashSet::new(),
@@ -2766,6 +2773,7 @@ impl Session {
27662773 self.streamed_message_ids.insert(id.to_string());
27672774 }
27682775 self.streaming = Some(StreamingMessage { blocks: HashMap::new() });
2776+ self.stream_clock = None;
27692777 }
27702778 Some("content_block_start") => {
27712779 let block = &event["content_block"];
@@ -2790,6 +2798,7 @@ impl Session {
27902798 }
27912799 }
27922800 Some("content_block_delta") => {
2801+ self.stream_clock.get_or_insert((Instant::now(), 0));
27932802 let Some(item_ix) = self.streamed_item(index) else { return };
27942803 let delta = &event["delta"];
27952804 match (delta.get("type").and_then(Value::as_str), self.items.get_mut(item_ix)) {
@@ -2815,14 +2824,34 @@ impl Session {
28152824 tool.input = input;
28162825 }
28172826 }
2827+ Some("message_delta") => {
2828+ if let (Some(output), Some(clock)) =
2829+ (event.pointer("/usage/output_tokens").and_then(Value::as_u64), self.stream_clock.as_mut())
2830+ {
2831+ clock.1 = output;
2832+ }
2833+ }
28182834 Some("message_stop") => {
28192835 self.thinking = false;
28202836 self.streaming = None;
2837+ self.finish_speed(None);
28212838 }
28222839 _ => {}
28232840 }
28242841 }
28252842
2843+ /// Works out how fast the response that just finished came out, from
2844+ /// its first token to its last.
2845+ fn finish_speed(&mut self, output: Option<u64>) {
2846+ let Some((started, reported)) = self.stream_clock.take() else { return };
2847+ let output = output.unwrap_or(reported);
2848+ let secs = started.elapsed().as_secs_f64();
2849+ // Too short a response says little about the model's speed.
2850+ if output >= 16 && secs >= 0.25 {
2851+ self.tokens_per_sec = Some(output as f64 / secs);
2852+ }
2853+ }
2854+
28262855 fn streamed_item(&self, index: u64) -> Option<usize> {
28272856 self.streaming.as_ref().and_then(|streaming| streaming.blocks.get(&index).copied())
28282857 }
@@ -3089,12 +3118,17 @@ impl Session {
30893118 }
30903119 Some("message_start") if message.pointer("/message/role").and_then(Value::as_str) == Some("assistant") => {
30913120 self.streaming = Some(StreamingMessage { blocks: HashMap::new() });
3121+ self.stream_clock = None;
3122+ }
3123+ Some("message_update") => {
3124+ self.stream_clock.get_or_insert((Instant::now(), 0));
3125+ self.handle_pi_update(&message["assistantMessageEvent"], cx)
30923126 }
3093- Some("message_update") => self.handle_pi_update(&message["assistantMessageEvent"], cx),
30943127 Some("message_end") if message.pointer("/message/role").and_then(Value::as_str) == Some("assistant") => {
30953128 let message = &message["message"];
30963129 self.thinking = false;
30973130 self.streaming = None;
3131+ self.finish_speed(message.pointer("/usage/output").and_then(Value::as_u64));
30983132 if let Some(usage) = message.get("usage") {
30993133 let tokens: u64 = ["input", "cacheRead", "cacheWrite", "output"]
31003134 .iter()
+15−0src/settings.rsView File
@@ -208,6 +208,17 @@ pub enum FollowUp {
208208 Queue,
209209 }
210210
211+ /// Whose plan limits the bar under the composer shows.
212+ #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
213+ #[serde(rename_all = "snake_case")]
214+ pub enum UsageBar {
215+ /// No bar.
216+ Off,
217+ #[default]
218+ Claude,
219+ Codex,
220+ }
221+
211222 /// How eagerly the app checks on things in the background.
212223 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
213224 #[serde(rename_all = "snake_case")]
@@ -411,6 +422,8 @@ pub struct Settings {
411422 pub plan_mode: bool,
412423 /// The ring showing how full the context window is.
413424 pub context_meter: bool,
425+ /// The bar under the composer: plan limits, context and output speed.
426+ pub usage_bar: UsageBar,
414427 /// Where the Add project browser opens; empty is the home folder.
415428 pub add_project_dir: String,
416429
@@ -575,6 +588,7 @@ impl Default for Settings {
575588 composer_context: false,
576589 plan_mode: false,
577590 context_meter: false,
591+ usage_bar: UsageBar::Claude,
578592 add_project_dir: String::new(),
579593 text_model: "claude:haiku".into(),
580594 text_reasoning: Some("low".into()),
@@ -698,6 +712,7 @@ impl Settings {
698712 ("Send shortcut", self.send_shortcut != d.send_shortcut),
699713 ("Follow-up behavior", self.follow_up != d.follow_up),
700714 ("Context window indicator", self.context_meter != d.context_meter),
715+ ("Usage bar", self.usage_bar != d.usage_bar),
701716 ("Plan mode", self.plan_mode != d.plan_mode),
702717 ("Sidebar", self.legacy_sidebar != d.legacy_sidebar),
703718 ("Response streaming", self.streaming != d.streaming),
+6−1src/ui/chat.rsView File
@@ -8,6 +8,7 @@ mod find;
88 mod footer;
99 mod strip;
1010 mod timeline;
11+ mod usage_bar;
1112
1213 pub(crate) use timeline::{describe_call, format_duration};
1314
@@ -49,6 +50,7 @@ use crate::ui::pickers::{ModelPicker, ModelPickerEvent};
4950 use crate::ui::workspace::ProjectDirectory;
5051
5152 pub use strip::BranchPicker;
53+ pub use usage_bar::refresh_limits;
5254
5355 const DEFAULT_PLACEHOLDER: &str = "Ask anything, @tag files/folders, $use skills, or / for commands";
5456 const MAX_TOOL_OUTPUT: usize = 6_000;
@@ -90,6 +92,7 @@ enum Picker {
9092 Environment,
9193 Workspace,
9294 Branch,
95+ Usage,
9396 }
9497
9598 /// A notice docked above the composer, T3 Code's banner stack.
@@ -1690,6 +1693,7 @@ impl ChatView {
16901693 let dock = self.render_dock(cx);
16911694 let collapsed = self.collapsed && !start && dock.is_empty();
16921695 let strip = self.strip_visible(cx).then(|| self.render_strip(cx));
1696+ let usage_bar = self.render_usage_bar(cx);
16931697
16941698 // MetalUI marks focus with a green-deep ring.
16951699 let mut card_shadow = p.raise.clone();
@@ -1806,7 +1810,8 @@ impl ChatView {
18061810 .children(dock)
18071811 // The strip is laid out below the card but painted first,
18081812 // so the card covers its top edge.
1809- .child(v_flex().w_full().flex_col_reverse().children(strip).child(card)),
1813+ .child(v_flex().w_full().flex_col_reverse().children(strip).child(card))
1814+ .children(usage_bar),
18101815 )
18111816 }
18121817
+304−0src/ui/chat/usage_bar.rsaddedView File
@@ -0,0 +1,304 @@
1+ //! The bar under the composer, like T3 Code's: how much of the plan's
2+ //! limits is used, Claude's or Codex's as Settings → General → Usage bar
3+ //! says, and how full the context window is and how fast the last response
4+ //! came out. Clicking the limits shows them in full and switches whose
5+ //! they are.
6+
7+ use std::time::{Duration, Instant, SystemTime};
8+
9+ use chrono::{DateTime, Local};
10+ use gpui_kit::component::{h_flex, v_flex};
11+ use gpui_kit::prelude::FluentBuilder as _;
12+ use gpui_kit::*;
13+
14+ use super::{ChatView, Picker, format_tokens};
15+ use crate::models::{Catalog, Provider};
16+ use crate::settings::{self, UsageBar};
17+ use crate::ui::composer;
18+ use crate::ui::metal::{self, MetalButton, Palette};
19+ use crate::ui::pickers;
20+ use crate::usage::{self, CodexSources, Limit};
21+
22+ /// How old limits get before they're asked for again while the bar shows them.
23+ const STALE: Duration = Duration::from_secs(180);
24+
25+ /// Codex's limits, asked of ChatGPT in the background.
26+ #[derive(Default)]
27+ pub struct CodexLimits {
28+ limits: Option<Vec<Limit>>,
29+ error: Option<String>,
30+ fetched: Option<Instant>,
31+ fetching: bool,
32+ }
33+
34+ impl Global for CodexLimits {}
35+
36+ /// Keeps the limits the bar shows fresh: Codex's from ChatGPT, Claude's
37+ /// from a health check of the Claude instance.
38+ pub fn refresh_limits(force: bool, cx: &mut App) {
39+ match settings::get(|settings| settings.usage_bar) {
40+ UsageBar::Off => {}
41+ UsageBar::Claude => {
42+ let stale = |checked: Option<SystemTime>| {
43+ force || checked.and_then(|at| at.elapsed().ok()).is_none_or(|age| age >= STALE)
44+ };
45+ let id = Catalog::global(cx)
46+ .instances
47+ .iter()
48+ .find(|state| state.kind() == Provider::Claude && state.config.enabled)
49+ .filter(|state| stale(state.checked))
50+ .map(|state| state.config.id.clone());
51+ if let Some(id) = id {
52+ Catalog::refresh(&id, cx);
53+ }
54+ }
55+ UsageBar::Codex => {
56+ let state = cx.default_global::<CodexLimits>();
57+ if state.fetching || (!force && state.fetched.is_some_and(|at| at.elapsed() < STALE)) {
58+ return;
59+ }
60+ state.fetching = true;
61+ let sources = CodexSources::with_pi(pi_dirs(cx));
62+ let fetch = cx.background_executor().spawn(async move { usage::codex_limits(&sources) });
63+ cx.spawn(async move |cx| {
64+ let result = fetch.await;
65+ cx.update(|cx| {
66+ let state = cx.default_global::<CodexLimits>();
67+ state.fetching = false;
68+ state.fetched = Some(Instant::now());
69+ match result {
70+ Ok(limits) => {
71+ state.limits = Some(limits);
72+ state.error = None;
73+ }
74+ Err(error) => state.error = Some(error),
75+ }
76+ cx.refresh_windows();
77+ })
78+ })
79+ .detach();
80+ }
81+ }
82+ }
83+
84+ /// The Pi instances' home folders, where a Codex login may live.
85+ fn pi_dirs(cx: &App) -> Vec<std::path::PathBuf> {
86+ let mut dirs: Vec<_> = Catalog::global(cx)
87+ .instances
88+ .iter()
89+ .filter(|state| state.kind() == Provider::Pi)
90+ .map(|state| match state.config.home.trim() {
91+ "" => settings::expand_home("~/.pi/agent"),
92+ home => settings::expand_home(home),
93+ })
94+ .collect();
95+ if dirs.is_empty() {
96+ dirs.push(settings::expand_home("~/.pi/agent"));
97+ }
98+ dirs.dedup();
99+ dirs
100+ }
101+
102+ /// The limits to show, or why there are none.
103+ enum Shown {
104+ Limits(Vec<Limit>),
105+ Loading,
106+ Missing(String),
107+ }
108+
109+ fn shown_limits(source: UsageBar, instance: &str, cx: &App) -> Shown {
110+ match source {
111+ UsageBar::Off => Shown::Missing(String::new()),
112+ UsageBar::Claude => {
113+ let claude = Catalog::global(cx).instances.iter().filter(|state| state.kind() == Provider::Claude);
114+ // The thread's own Claude account, else the first that reports limits.
115+ let state = claude.clone().find(|state| state.config.id == instance && state.usage.is_some()).or_else(|| {
116+ claude.clone().find(|state| state.usage.is_some())
117+ });
118+ match state.and_then(|state| state.usage.as_ref()) {
119+ Some(reply) => Shown::Limits(usage::limits_from_claude(reply)),
120+ None if claude.clone().any(|state| state.checked.is_none() && state.config.enabled) => Shown::Loading,
121+ None => Shown::Missing("Claude reports no plan limits for this login.".into()),
122+ }
123+ }
124+ UsageBar::Codex => match cx.try_global::<CodexLimits>() {
125+ Some(CodexLimits { limits: Some(limits), .. }) => Shown::Limits(limits.clone()),
126+ Some(CodexLimits { error: Some(error), .. }) => Shown::Missing(error.clone()),
127+ _ => Shown::Loading,
128+ },
129+ }
130+ }
131+
132+ fn source_name(source: UsageBar) -> &'static str {
133+ match source {
134+ UsageBar::Codex => "Codex",
135+ _ => "Claude",
136+ }
137+ }
138+
139+ fn limit_color(limit: &Limit, p: &Palette) -> Hsla {
140+ match limit.severity.as_str() {
141+ "critical" => p.red,
142+ "warning" => p.amber,
143+ _ => p.ink3,
144+ }
145+ }
146+
147+ /// `5h ▬▬ 12%`.
148+ fn limit_chip(limit: &Limit, p: &Palette) -> Div {
149+ let fraction = (limit.percent / 100.).clamp(0., 1.) as f32;
150+ h_flex()
151+ .gap(px(6.))
152+ .child(div().text_color(p.ink3).child(limit.short.clone().unwrap_or_default()))
153+ .child(
154+ div()
155+ .w(px(44.))
156+ .h(px(4.))
157+ .rounded_full()
158+ .bg(p.ink3.opacity(0.22))
159+ .child(div().h_full().rounded_full().bg(limit_color(limit, p)).w(relative(fraction))),
160+ )
161+ .child(div().text_color(p.ink2).child(format!("{:.0}%", limit.percent)))
162+ }
163+
164+ /// `Resets in 2h 14m`.
165+ fn resets_text(at: DateTime<Local>, now: DateTime<Local>) -> String {
166+ let left = at.signed_duration_since(now);
167+ if left.num_seconds() <= 0 {
168+ "Reset".into()
169+ } else if left.num_hours() >= 24 {
170+ format!("Resets in {}d {}h", left.num_days(), left.num_hours() % 24)
171+ } else if left.num_minutes() >= 60 {
172+ format!("Resets in {}h {}m", left.num_hours(), left.num_minutes() % 60)
173+ } else {
174+ format!("Resets in {}m", left.num_minutes().max(1))
175+ }
176+ }
177+
178+ impl ChatView {
179+ /// The bar under the composer, unless Settings → General → Usage bar
180+ /// turns it off.
181+ pub(super) fn render_usage_bar(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
182+ let source = settings::get(|settings| settings.usage_bar);
183+ if source == UsageBar::Off {
184+ return None;
185+ }
186+ let p = metal::palette(cx);
187+ let session = self.session.read(cx);
188+ let (tokens, window, speed) = (session.context_tokens, session.context_window, session.tokens_per_sec);
189+
190+ let leading = match shown_limits(source, &session.instance, cx) {
191+ Shown::Limits(limits) if limits.iter().any(|limit| limit.short.is_some()) => h_flex()
192+ .gap(px(14.))
193+ .children(limits.iter().filter(|limit| limit.short.is_some()).map(|limit| limit_chip(limit, p))),
194+ Shown::Limits(_) | Shown::Missing(_) => {
195+ h_flex().text_color(p.ink3).child(format!("{} limits unavailable", source_name(source)))
196+ }
197+ Shown::Loading => h_flex().text_color(p.ink3).child(format!("{} limits…", source_name(source))),
198+ };
199+ let trigger = MetalButton::new("usage-limits")
200+ .ghost()
201+ .mini()
202+ .w_auto()
203+ .h(px(22.))
204+ .px(px(6.))
205+ .text_size(px(12.))
206+ .leading(leading)
207+ .tooltip(format!("{} plan limits · click for details", source_name(source)));
208+ let limits = self.picker_anchored(Picker::Usage, trigger, Anchor::BottomLeft, cx, |this, cx| {
209+ this.render_usage_menu(cx)
210+ });
211+
212+ let figure = |value: String, unit: &'static str| {
213+ h_flex().gap(px(4.)).child(div().text_color(p.ink2).child(value)).child(div().text_color(p.ink3).child(unit))
214+ };
215+ let context = match (tokens, window) {
216+ (Some(tokens), Some(window)) => {
217+ Some(figure(format!("{:.0}%", tokens as f64 * 100. / window.max(1) as f64), "context"))
218+ }
219+ (Some(tokens), None) => Some(figure(format_tokens(tokens), "context")),
220+ _ => None,
221+ };
222+ let speed = speed.map(|speed| figure(format!("{speed:.0}"), "tok/s"));
223+ let dot = (context.is_some() && speed.is_some()).then(|| div().text_color(p.ink3.opacity(0.6)).child("·"));
224+
225+ Some(
226+ h_flex()
227+ .w_full()
228+ .justify_between()
229+ .gap(px(12.))
230+ .pt(px(6.))
231+ .pl(px(14.))
232+ .pr(px(20.))
233+ .text_size(px(12.))
234+ .child(limits)
235+ .child(h_flex().flex_none().gap(px(8.)).children(context).children(dot).children(speed))
236+ .into_any_element(),
237+ )
238+ }
239+
240+ /// Each limit with when it resets, and whose limits the bar shows.
241+ fn render_usage_menu(&mut self, cx: &mut Context<Self>) -> AnyElement {
242+ let p = metal::palette(cx);
243+ let source = settings::get(|settings| settings.usage_bar);
244+ let shown = shown_limits(source, &self.session.read(cx).instance, cx);
245+ let now = Local::now();
246+ let mut menu = pickers::panel(p).w(px(300.)).child(composer::menu_label(format!("{} plan limits", source_name(source)), p));
247+ match shown {
248+ Shown::Limits(limits) if !limits.is_empty() => {
249+ for limit in &limits {
250+ menu = menu.child(
251+ v_flex()
252+ .px(px(8.))
253+ .py(px(6.))
254+ .gap(px(5.))
255+ .text_size(px(12.5))
256+ .child(
257+ h_flex()
258+ .justify_between()
259+ .child(div().text_color(p.ink).child(limit.label.clone()))
260+ .child(div().text_color(p.ink2).child(format!("{:.0}% used", limit.percent))),
261+ )
262+ .child(
263+ div().h(px(5.)).w_full().rounded_full().bg(p.ink3.opacity(0.18)).child(
264+ div()
265+ .h_full()
266+ .rounded_full()
267+ .bg(limit_color(limit, p))
268+ .w(relative((limit.percent / 100.).clamp(0., 1.) as f32)),
269+ ),
270+ )
271+ .when_some(limit.resets_at, |this, at| {
272+ this.child(div().text_size(px(11.5)).text_color(p.ink3).child(resets_text(at, now)))
273+ }),
274+ );
275+ }
276+ }
277+ Shown::Loading => menu = menu.child(note("Checking…", p)),
278+ Shown::Limits(_) => menu = menu.child(note("This login reports no limits.", p)),
279+ Shown::Missing(why) => menu = menu.child(note(why, p)),
280+ }
281+ menu = menu.child(composer::menu_separator(p)).child(composer::menu_label("Show limits from", p));
282+ for (option, label) in [(UsageBar::Claude, "Claude"), (UsageBar::Codex, "Codex")] {
283+ menu = menu.child(composer::menu_item(SharedString::from(format!("usage-source-{label}")), option == source, true, p).child(label).on_click(
284+ cx.listener(move |this, _, window, cx| {
285+ settings::update(|settings| settings.usage_bar = option);
286+ refresh_limits(false, cx);
287+ this.set_picker(Picker::Usage, false, window, cx);
288+ }),
289+ ));
290+ }
291+ menu.child(composer::menu_separator(p))
292+ .child(composer::menu_item("usage-bar-hide", false, true, p).child("Hide usage bar").on_click(cx.listener(
293+ |this, _, window, cx| {
294+ settings::update(|settings| settings.usage_bar = UsageBar::Off);
295+ this.set_picker(Picker::Usage, false, window, cx);
296+ },
297+ )))
298+ .into_any_element()
299+ }
300+ }
301+
302+ fn note(text: impl Into<SharedString>, p: &Palette) -> Div {
303+ div().px(px(8.)).py(px(6.)).text_size(px(12.)).text_color(p.ink3).child(text.into())
304+ }
+9−1src/ui/settings/general.rsView File
@@ -16,7 +16,7 @@ use crate::models::Catalog;
1616 use crate::session::Reasoning;
1717 use crate::settings::{
1818 BackgroundActivity, FollowUp, ProjectOrder, QuitShortcut, SendShortcut, Streaming, Submodules,
19- TimeFormat, WorkspaceMode,
19+ TimeFormat, UsageBar, WorkspaceMode,
2020 };
2121 use crate::ui::metal::{self, MetalButton};
2222 use crate::ui::{pickers, workspace};
@@ -52,6 +52,7 @@ const SEND_SHORTCUTS: &[(SendShortcut, &str)] = &[
5252 (SendShortcut::ModEnter, "Ctrl + Enter always"),
5353 ];
5454 const FOLLOW_UPS: &[(FollowUp, &str)] = &[(FollowUp::Queue, "Queue"), (FollowUp::Steer, "Steer")];
55+ const USAGE_BARS: &[(UsageBar, &str)] = &[(UsageBar::Claude, "Claude"), (UsageBar::Codex, "Codex"), (UsageBar::Off, "Off")];
5556 const BACKGROUND: &[(BackgroundActivity, &str)] = &[
5657 (BackgroundActivity::Balanced, "Balanced"),
5758 (BackgroundActivity::Always, "Performance"),
@@ -416,6 +417,13 @@ impl SettingsView {
416417 settings.follow_up = follow_up
417418 })),
418419 )
420+ .row(
421+ row("Usage bar")
422+ .id("usage-bar")
423+ .description("Show plan limits, context and output speed under the composer, with limits from Claude or Codex.")
424+ .reset(reset(&view, cx, |s| s.usage_bar, |s, bar| s.usage_bar = bar))
425+ .control(choice("usage-bar", saved.usage_bar, USAGE_BARS, &view, |settings, bar| settings.usage_bar = bar)),
426+ )
419427 .row(
420428 row("Provider update checks")
421429 .id("provider-update-checks")
+1−0src/ui/settings/search.rsView File
@@ -84,6 +84,7 @@ pub const ITEMS: &[Item] = &[
8484 item("composer-collapse", "Collapse composer on scroll", Page::General, "composer rest resting scroll wheel conversation shrink minimize"),
8585 item("send-shortcut", "Send shortcut", Page::General, "enter return ctrl multiline prompt new line composer"),
8686 item("follow-up-behavior", "Follow-up behavior", Page::General, "queue steer running turn send default behavior composer"),
87+ item("usage-bar", "Usage bar", Page::General, "limits plan session weekly 5h codex claude chatgpt context tokens speed tok/s composer"),
8788 item("provider-update-checks", "Provider update checks", Page::General, "installed cli versions newer available claude pi jcode"),
8889 item(
8990 "continue-threads-after-server-update",
+3−0src/ui/workspace.rsView File
@@ -601,6 +601,9 @@ impl Workspace {
601601 self.last_health = Instant::now();
602602 Catalog::refresh_all(cx);
603603 }
604+ if allowed {
605+ crate::ui::chat::refresh_limits(false, cx);
606+ }
604607 if let Some((_, armed)) = self.settle_armed
605608 && armed.elapsed() >= SETTLE_ARMED
606609 {
+238−0src/usage.rsView File
@@ -784,6 +784,9 @@ pub fn format_cost(dollars: f64) -> String {
784784 #[derive(Clone, Debug, PartialEq)]
785785 pub struct Limit {
786786 pub label: String,
787+ /// What the bar under the composer calls it (`5h`, `Week`), for the
788+ /// limits it shows.
789+ pub short: Option<String>,
787790 /// How much of it is used, 0–100.
788791 pub percent: f64,
789792 pub resets_at: Option<DateTime<Local>>,
@@ -814,6 +817,7 @@ pub fn limits_from_claude(reply: &Value) -> Vec<Limit> {
814817 let percent = window.get("utilization").and_then(Value::as_f64)?;
815818 Some(Limit {
816819 label: label.to_string(),
820+ short: short_claude_label(key),
817821 percent,
818822 resets_at: window.get("resets_at").and_then(parse_time).map(|at| at.with_timezone(&Local)),
819823 severity: severity_of(percent).to_string(),
@@ -839,6 +843,7 @@ fn claude_limit(limit: &Value) -> Option<Limit> {
839843 };
840844 Some(Limit {
841845 label,
846+ short: short_claude_label(kind),
842847 percent,
843848 resets_at: limit.get("resets_at").and_then(parse_time).map(|at| at.with_timezone(&Local)),
844849 severity: limit
@@ -850,6 +855,15 @@ fn claude_limit(limit: &Value) -> Option<Limit> {
850855 })
851856 }
852857
858+ /// The session and all-models weekly limits are the ones the bar shows.
859+ fn short_claude_label(kind: &str) -> Option<String> {
860+ match kind {
861+ "session" | "five_hour" => Some("5h".into()),
862+ "weekly_all" | "seven_day" => Some("Week".into()),
863+ _ => None,
864+ }
865+ }
866+
853867 fn severity_of(percent: f64) -> &'static str {
854868 if percent >= 90. {
855869 "critical"
@@ -870,6 +884,183 @@ fn humanize(key: &str) -> String {
870884 }
871885 }
872886
887+ // ---- Codex limits ------------------------------------------------------------------
888+
889+ /// Where Codex's limits are read from: the ChatGPT logins of the Codex CLI
890+ /// and Pi, and the Codex CLI's session logs.
891+ #[derive(Clone, Debug, Default)]
892+ pub struct CodexSources {
893+ /// Codex CLI homes, such as `~/.codex`.
894+ pub codex_homes: Vec<PathBuf>,
895+ /// Pi agent directories, whose `auth.json` may hold an `openai-codex` login.
896+ pub pi_dirs: Vec<PathBuf>,
897+ }
898+
899+ impl CodexSources {
900+ /// `$CODEX_HOME` or `~/.codex`, and the given Pi directories.
901+ pub fn with_pi(pi_dirs: Vec<PathBuf>) -> Self {
902+ let codex_home = std::env::var_os("CODEX_HOME")
903+ .filter(|home| !home.is_empty())
904+ .map(PathBuf::from)
905+ .unwrap_or_else(|| crate::settings::expand_home("~/.codex"));
906+ Self { codex_homes: vec![codex_home], pi_dirs }
907+ }
908+ }
909+
910+ /// A ChatGPT login Codex's usage can be asked with.
911+ struct CodexLogin {
912+ access_token: String,
913+ account_id: Option<String>,
914+ }
915+
916+ const CODEX_USAGE_URL: &str = "https://chatgpt.com/backend-api/wham/usage";
917+
918+ /// Asks ChatGPT how much of the Codex plan's limits is used, with the first
919+ /// login that works; failing that, takes the newest limits the Codex CLI
920+ /// logged. Blocks.
921+ pub fn codex_limits(sources: &CodexSources) -> Result<Vec<Limit>, String> {
922+ let mut error = None;
923+ for login in codex_logins(sources) {
924+ let mut headers = vec![("Authorization", format!("Bearer {}", login.access_token))];
925+ if let Some(account) = &login.account_id {
926+ headers.push(("ChatGPT-Account-Id", account.clone()));
927+ }
928+ let headers: Vec<(&str, &str)> = headers.iter().map(|(name, value)| (*name, value.as_str())).collect();
929+ match crate::http::get(CODEX_USAGE_URL, &headers, Duration::from_secs(15)) {
930+ Ok(body) => match serde_json::from_str::<Value>(&body) {
931+ Ok(reply) => return Ok(limits_from_codex(&reply, Local::now())),
932+ Err(err) => error = Some(err.to_string()),
933+ },
934+ Err(err) => error = Some(err),
935+ }
936+ }
937+ let now = Local::now();
938+ for home in &sources.codex_homes {
939+ if let Some(limits) = codex_logged_limits(&home.join("sessions"), now) {
940+ return Ok(limits);
941+ }
942+ }
943+ Err(error.unwrap_or_else(|| "No Codex login found. Sign in with the Codex CLI or Pi.".into()))
944+ }
945+
946+ fn codex_logins(sources: &CodexSources) -> Vec<CodexLogin> {
947+ let read = |path: PathBuf| std::fs::read_to_string(path).ok().and_then(|text| serde_json::from_str::<Value>(&text).ok());
948+ let mut logins = Vec::new();
949+ for home in &sources.codex_homes {
950+ let Some(auth) = read(home.join("auth.json")) else { continue };
951+ let Some(token) = auth.pointer("/tokens/access_token").and_then(Value::as_str) else { continue };
952+ let account_id = auth.pointer("/tokens/account_id").and_then(Value::as_str).map(str::to_string);
953+ logins.push(CodexLogin { access_token: token.to_string(), account_id: account_id.or_else(|| jwt_account_id(token)) });
954+ }
955+ for dir in &sources.pi_dirs {
956+ let Some(auth) = read(dir.join("auth.json")) else { continue };
957+ let Some(login) = auth.get("openai-codex") else { continue };
958+ let Some(token) = login.get("access").and_then(Value::as_str) else { continue };
959+ // Pi refreshes its token itself; an expired one would only be refused.
960+ let expires = login.get("expires").and_then(Value::as_i64);
961+ if expires.is_some_and(|expires| expires < Utc::now().timestamp_millis()) {
962+ continue;
963+ }
964+ let account_id = login.get("accountId").and_then(Value::as_str).map(str::to_string);
965+ logins.push(CodexLogin { access_token: token.to_string(), account_id: account_id.or_else(|| jwt_account_id(token)) });
966+ }
967+ logins
968+ }
969+
970+ /// The ChatGPT account an OpenAI access token is for.
971+ fn jwt_account_id(token: &str) -> Option<String> {
972+ use base64::Engine as _;
973+ let payload = token.split('.').nth(1)?;
974+ let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload.trim_end_matches('=')).ok()?;
975+ let claims: Value = serde_json::from_slice(&bytes).ok()?;
976+ claims.pointer("/https:~1~1api.openai.com~1auth/chatgpt_account_id").and_then(Value::as_str).map(str::to_string)
977+ }
978+
979+ /// Reads ChatGPT's usage reply: a primary (five-hour) and a secondary
980+ /// (weekly) window.
981+ pub fn limits_from_codex(reply: &Value, now: DateTime<Local>) -> Vec<Limit> {
982+ let Some(rate_limit) = reply.get("rate_limit") else { return Vec::new() };
983+ ["primary_window", "secondary_window"]
984+ .iter()
985+ .filter_map(|key| {
986+ let window = rate_limit.get(*key)?;
987+ let percent = window.get("used_percent").and_then(Value::as_f64)?;
988+ let minutes = window.get("limit_window_seconds").and_then(Value::as_i64).map(|secs| secs / 60);
989+ let resets_at = window
990+ .get("reset_at")
991+ .and_then(Value::as_i64)
992+ .and_then(|secs| DateTime::from_timestamp(secs, 0))
993+ .map(|at| at.with_timezone(&Local))
994+ .or_else(|| {
995+ let secs = window.get("reset_after_seconds").and_then(Value::as_i64)?;
996+ Some(now + TimeDelta::seconds(secs))
997+ });
998+ Some(codex_limit(percent, minutes, resets_at))
999+ })
1000+ .collect()
1001+ }
1002+
1003+ /// The limits in the newest `token_count` event of the Codex CLI's session
1004+ /// logs, with windows that have reset since shown empty.
1005+ fn codex_logged_limits(sessions: &Path, now: DateTime<Local>) -> Option<Vec<Limit>> {
1006+ let mut files: Vec<(SystemTime, PathBuf)> = jsonl_files(sessions, DateTime::UNIX_EPOCH)
1007+ .into_iter()
1008+ .filter_map(|path| Some((std::fs::metadata(&path).ok()?.modified().ok()?, path)))
1009+ .collect();
1010+ files.sort_by_key(|(modified, _)| std::cmp::Reverse(*modified));
1011+ files.into_iter().take(5).find_map(|(_, path)| {
1012+ let mut newest = None;
1013+ scan_lines(&path, &["\"token_count\"", "\"rate_limits\""], |text| {
1014+ if let Ok(line) = serde_json::from_str::<Value>(text) {
1015+ newest = Some(line);
1016+ }
1017+ });
1018+ newest.and_then(|line| limits_from_codex_event(&line, now))
1019+ })
1020+ }
1021+
1022+ /// Reads an `event_msg` line's `token_count` payload, whose `rate_limits`
1023+ /// have a `primary` and a `secondary` window.
1024+ fn limits_from_codex_event(line: &Value, now: DateTime<Local>) -> Option<Vec<Limit>> {
1025+ let rate_limits = line.pointer("/payload/rate_limits").filter(|limits| limits.is_object())?;
1026+ let logged_at = line.get("timestamp").and_then(parse_time).map(|at| at.with_timezone(&Local)).unwrap_or(now);
1027+ let limits: Vec<Limit> = ["primary", "secondary"]
1028+ .iter()
1029+ .filter_map(|key| {
1030+ let window = rate_limits.get(*key)?;
1031+ let mut percent = window.get("used_percent").and_then(Value::as_f64)?;
1032+ let minutes = window.get("window_minutes").and_then(Value::as_i64);
1033+ let resets_at = match window.get("resets_at").and_then(Value::as_i64) {
1034+ Some(secs) => DateTime::from_timestamp(secs, 0).map(|at| at.with_timezone(&Local)),
1035+ None => window.get("resets_in_seconds").and_then(Value::as_i64).map(|secs| logged_at + TimeDelta::seconds(secs)),
1036+ };
1037+ if resets_at.is_some_and(|at| at <= now) {
1038+ percent = 0.;
1039+ }
1040+ Some(codex_limit(percent, minutes, resets_at))
1041+ })
1042+ .collect();
1043+ (!limits.is_empty()).then_some(limits)
1044+ }
1045+
1046+ fn codex_limit(percent: f64, minutes: Option<i64>, resets_at: Option<DateTime<Local>>) -> Limit {
1047+ let short = match minutes {
1048+ Some(300) => "5h".to_string(),
1049+ Some(10080) => "Week".to_string(),
1050+ Some(minutes) if minutes > 0 && minutes % 1440 == 0 => format!("{}d", minutes / 1440),
1051+ Some(minutes) if minutes > 0 && minutes % 60 == 0 => format!("{}h", minutes / 60),
1052+ Some(minutes) if minutes > 0 => format!("{minutes}m"),
1053+ _ => "Limit".to_string(),
1054+ };
1055+ let label = match short.as_str() {
1056+ "5h" => "5-hour limit".to_string(),
1057+ "Week" => "Weekly limit".to_string(),
1058+ "Limit" => "Limit".to_string(),
1059+ other => format!("{other} limit"),
1060+ };
1061+ Limit { label, short: Some(short), percent, resets_at, severity: severity_of(percent).to_string(), active: false }
1062+ }
1063+
8731064 #[cfg(test)]
8741065 mod tests {
8751066 use super::*;
@@ -1033,6 +1224,52 @@ mod tests {
10331224 );
10341225 }
10351226
1227+ #[test]
1228+ fn limits_from_chatgpt_usage() {
1229+ let now = Local::now();
1230+ let reply = json!({"plan_type": "plus", "rate_limit": {
1231+ "primary_window": {"used_percent": 12, "limit_window_seconds": 18000, "reset_after_seconds": 600, "reset_at": 1_900_000_000},
1232+ "secondary_window": {"used_percent": 62.5, "limit_window_seconds": 604800, "reset_after_seconds": 3600},
1233+ }});
1234+ let limits = limits_from_codex(&reply, now);
1235+ assert_eq!(limits.len(), 2);
1236+ assert_eq!(limits[0].short.as_deref(), Some("5h"));
1237+ assert_eq!(limits[0].percent, 12.);
1238+ assert_eq!(limits[0].resets_at.map(|at| at.timestamp()), Some(1_900_000_000));
1239+ assert_eq!(limits[1].short.as_deref(), Some("Week"));
1240+ assert_eq!(limits[1].label, "Weekly limit");
1241+ assert_eq!(limits[1].resets_at.map(|at| at.timestamp()), Some((now + TimeDelta::seconds(3600)).timestamp()));
1242+ assert!(limits_from_codex(&json!({}), now).is_empty());
1243+ }
1244+
1245+ #[test]
1246+ fn limits_from_codex_logs() {
1247+ let line = json!({"timestamp": "2026-10-11T10:00:00.000Z", "type": "event_msg", "payload": {
1248+ "type": "token_count",
1249+ "rate_limits": {
1250+ "primary": {"used_percent": 40.0, "window_minutes": 300, "resets_in_seconds": 3600},
1251+ "secondary": {"used_percent": 70.0, "window_minutes": 10080, "resets_at": 1_900_000_000},
1252+ },
1253+ }});
1254+ let before = DateTime::parse_from_rfc3339("2026-10-11T10:30:00Z").unwrap().with_timezone(&Local);
1255+ let limits = limits_from_codex_event(&line, before).unwrap();
1256+ assert_eq!((limits[0].percent, limits[1].percent), (40., 70.));
1257+ // Once the five-hour window has reset, nothing of it is used.
1258+ let after = DateTime::parse_from_rfc3339("2026-10-11T11:30:00Z").unwrap().with_timezone(&Local);
1259+ let limits = limits_from_codex_event(&line, after).unwrap();
1260+ assert_eq!((limits[0].percent, limits[1].percent), (0., 70.));
1261+ assert!(limits_from_codex_event(&json!({"payload": {"rate_limits": null}}), after).is_none());
1262+ }
1263+
1264+ #[test]
1265+ fn account_id_from_access_token() {
1266+ use base64::Engine as _;
1267+ let claims = json!({"https://api.openai.com/auth": {"chatgpt_account_id": "acct-1"}});
1268+ let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(claims.to_string());
1269+ assert_eq!(jwt_account_id(&format!("header.{payload}.sig")).as_deref(), Some("acct-1"));
1270+ assert_eq!(jwt_account_id("not-a-token"), None);
1271+ }
1272+
10361273 #[test]
10371274 fn limits_from_named_windows() {
10381275 let reply = json!({"rate_limits": {
@@ -1043,6 +1280,7 @@ mod tests {
10431280 let limits = limits_from_claude(&reply);
10441281 assert_eq!(limits.len(), 2);
10451282 assert_eq!(limits[0].label, "Current session");
1283+ assert_eq!(limits[0].short.as_deref(), Some("5h"));
10461284 assert_eq!(limits[0].severity, "critical");
10471285 assert_eq!(limits[1].label, "Weekly · all models");
10481286 assert_eq!(limits[1].resets_at, None);