Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Showing 8 changed files with 611 additions and 3 deletions.
| @@ -910,6 +910,11 @@ pub struct Session { | ||
| 910 | 910 | /// Tokens in the context window as of the last response. |
| 911 | 911 | pub context_tokens: Option<u64>, |
| 912 | 912 | 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)>, | |
| 913 | 918 | /// Tasks running in the background, oldest first. |
| 914 | 919 | pub background_tasks: Vec<BackgroundTask>, |
| 915 | 920 | /// When each task the CLI reported started, and the tool that started it. |
| @@ -1061,6 +1066,8 @@ impl Session { | ||
| 1061 | 1066 | worktree_setup: None, |
| 1062 | 1067 | context_tokens: None, |
| 1063 | 1068 | context_window: None, |
| 1069 | + tokens_per_sec: None, | |
| 1070 | + stream_clock: None, | |
| 1064 | 1071 | background_tasks: Vec::new(), |
| 1065 | 1072 | task_origins: HashMap::new(), |
| 1066 | 1073 | expanded_turns: HashSet::new(), |
| @@ -2766,6 +2773,7 @@ impl Session { | ||
| 2766 | 2773 | self.streamed_message_ids.insert(id.to_string()); |
| 2767 | 2774 | } |
| 2768 | 2775 | self.streaming = Some(StreamingMessage { blocks: HashMap::new() }); |
| 2776 | + self.stream_clock = None; | |
| 2769 | 2777 | } |
| 2770 | 2778 | Some("content_block_start") => { |
| 2771 | 2779 | let block = &event["content_block"]; |
| @@ -2790,6 +2798,7 @@ impl Session { | ||
| 2790 | 2798 | } |
| 2791 | 2799 | } |
| 2792 | 2800 | Some("content_block_delta") => { |
| 2801 | + self.stream_clock.get_or_insert((Instant::now(), 0)); | |
| 2793 | 2802 | let Some(item_ix) = self.streamed_item(index) else { return }; |
| 2794 | 2803 | let delta = &event["delta"]; |
| 2795 | 2804 | match (delta.get("type").and_then(Value::as_str), self.items.get_mut(item_ix)) { |
| @@ -2815,14 +2824,34 @@ impl Session { | ||
| 2815 | 2824 | tool.input = input; |
| 2816 | 2825 | } |
| 2817 | 2826 | } |
| 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 | + } | |
| 2818 | 2834 | Some("message_stop") => { |
| 2819 | 2835 | self.thinking = false; |
| 2820 | 2836 | self.streaming = None; |
| 2837 | + self.finish_speed(None); | |
| 2821 | 2838 | } |
| 2822 | 2839 | _ => {} |
| 2823 | 2840 | } |
| 2824 | 2841 | } |
| 2825 | 2842 | |
| 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 | + | |
| 2826 | 2855 | fn streamed_item(&self, index: u64) -> Option<usize> { |
| 2827 | 2856 | self.streaming.as_ref().and_then(|streaming| streaming.blocks.get(&index).copied()) |
| 2828 | 2857 | } |
| @@ -3089,12 +3118,17 @@ impl Session { | ||
| 3089 | 3118 | } |
| 3090 | 3119 | Some("message_start") if message.pointer("/message/role").and_then(Value::as_str) == Some("assistant") => { |
| 3091 | 3120 | 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) | |
| 3092 | 3126 | } |
| 3093 | - Some("message_update") => self.handle_pi_update(&message["assistantMessageEvent"], cx), | |
| 3094 | 3127 | Some("message_end") if message.pointer("/message/role").and_then(Value::as_str) == Some("assistant") => { |
| 3095 | 3128 | let message = &message["message"]; |
| 3096 | 3129 | self.thinking = false; |
| 3097 | 3130 | self.streaming = None; |
| 3131 | + self.finish_speed(message.pointer("/usage/output").and_then(Value::as_u64)); | |
| 3098 | 3132 | if let Some(usage) = message.get("usage") { |
| 3099 | 3133 | let tokens: u64 = ["input", "cacheRead", "cacheWrite", "output"] |
| 3100 | 3134 | .iter() |
| @@ -208,6 +208,17 @@ pub enum FollowUp { | ||
| 208 | 208 | Queue, |
| 209 | 209 | } |
| 210 | 210 | |
| 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 | + | |
| 211 | 222 | /// How eagerly the app checks on things in the background. |
| 212 | 223 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] |
| 213 | 224 | #[serde(rename_all = "snake_case")] |
| @@ -411,6 +422,8 @@ pub struct Settings { | ||
| 411 | 422 | pub plan_mode: bool, |
| 412 | 423 | /// The ring showing how full the context window is. |
| 413 | 424 | pub context_meter: bool, |
| 425 | + /// The bar under the composer: plan limits, context and output speed. | |
| 426 | + pub usage_bar: UsageBar, | |
| 414 | 427 | /// Where the Add project browser opens; empty is the home folder. |
| 415 | 428 | pub add_project_dir: String, |
| 416 | 429 | |
| @@ -575,6 +588,7 @@ impl Default for Settings { | ||
| 575 | 588 | composer_context: false, |
| 576 | 589 | plan_mode: false, |
| 577 | 590 | context_meter: false, |
| 591 | + usage_bar: UsageBar::Claude, | |
| 578 | 592 | add_project_dir: String::new(), |
| 579 | 593 | text_model: "claude:haiku".into(), |
| 580 | 594 | text_reasoning: Some("low".into()), |
| @@ -698,6 +712,7 @@ impl Settings { | ||
| 698 | 712 | ("Send shortcut", self.send_shortcut != d.send_shortcut), |
| 699 | 713 | ("Follow-up behavior", self.follow_up != d.follow_up), |
| 700 | 714 | ("Context window indicator", self.context_meter != d.context_meter), |
| 715 | + ("Usage bar", self.usage_bar != d.usage_bar), | |
| 701 | 716 | ("Plan mode", self.plan_mode != d.plan_mode), |
| 702 | 717 | ("Sidebar", self.legacy_sidebar != d.legacy_sidebar), |
| 703 | 718 | ("Response streaming", self.streaming != d.streaming), |
| @@ -8,6 +8,7 @@ mod find; | ||
| 8 | 8 | mod footer; |
| 9 | 9 | mod strip; |
| 10 | 10 | mod timeline; |
| 11 | + mod usage_bar; | |
| 11 | 12 | |
| 12 | 13 | pub(crate) use timeline::{describe_call, format_duration}; |
| 13 | 14 | |
| @@ -49,6 +50,7 @@ use crate::ui::pickers::{ModelPicker, ModelPickerEvent}; | ||
| 49 | 50 | use crate::ui::workspace::ProjectDirectory; |
| 50 | 51 | |
| 51 | 52 | pub use strip::BranchPicker; |
| 53 | + pub use usage_bar::refresh_limits; | |
| 52 | 54 | |
| 53 | 55 | const DEFAULT_PLACEHOLDER: &str = "Ask anything, @tag files/folders, $use skills, or / for commands"; |
| 54 | 56 | const MAX_TOOL_OUTPUT: usize = 6_000; |
| @@ -90,6 +92,7 @@ enum Picker { | ||
| 90 | 92 | Environment, |
| 91 | 93 | Workspace, |
| 92 | 94 | Branch, |
| 95 | + Usage, | |
| 93 | 96 | } |
| 94 | 97 | |
| 95 | 98 | /// A notice docked above the composer, T3 Code's banner stack. |
| @@ -1690,6 +1693,7 @@ impl ChatView { | ||
| 1690 | 1693 | let dock = self.render_dock(cx); |
| 1691 | 1694 | let collapsed = self.collapsed && !start && dock.is_empty(); |
| 1692 | 1695 | let strip = self.strip_visible(cx).then(|| self.render_strip(cx)); |
| 1696 | + let usage_bar = self.render_usage_bar(cx); | |
| 1693 | 1697 | |
| 1694 | 1698 | // MetalUI marks focus with a green-deep ring. |
| 1695 | 1699 | let mut card_shadow = p.raise.clone(); |
| @@ -1806,7 +1810,8 @@ impl ChatView { | ||
| 1806 | 1810 | .children(dock) |
| 1807 | 1811 | // The strip is laid out below the card but painted first, |
| 1808 | 1812 | // 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), | |
| 1810 | 1815 | ) |
| 1811 | 1816 | } |
| 1812 | 1817 | |
| @@ -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 | + } | |
| @@ -16,7 +16,7 @@ use crate::models::Catalog; | ||
| 16 | 16 | use crate::session::Reasoning; |
| 17 | 17 | use crate::settings::{ |
| 18 | 18 | BackgroundActivity, FollowUp, ProjectOrder, QuitShortcut, SendShortcut, Streaming, Submodules, |
| 19 | - TimeFormat, WorkspaceMode, | |
| 19 | + TimeFormat, UsageBar, WorkspaceMode, | |
| 20 | 20 | }; |
| 21 | 21 | use crate::ui::metal::{self, MetalButton}; |
| 22 | 22 | use crate::ui::{pickers, workspace}; |
| @@ -52,6 +52,7 @@ const SEND_SHORTCUTS: &[(SendShortcut, &str)] = &[ | ||
| 52 | 52 | (SendShortcut::ModEnter, "Ctrl + Enter always"), |
| 53 | 53 | ]; |
| 54 | 54 | 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")]; | |
| 55 | 56 | const BACKGROUND: &[(BackgroundActivity, &str)] = &[ |
| 56 | 57 | (BackgroundActivity::Balanced, "Balanced"), |
| 57 | 58 | (BackgroundActivity::Always, "Performance"), |
| @@ -416,6 +417,13 @@ impl SettingsView { | ||
| 416 | 417 | settings.follow_up = follow_up |
| 417 | 418 | })), |
| 418 | 419 | ) |
| 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 | + ) | |
| 419 | 427 | .row( |
| 420 | 428 | row("Provider update checks") |
| 421 | 429 | .id("provider-update-checks") |
| @@ -84,6 +84,7 @@ pub const ITEMS: &[Item] = &[ | ||
| 84 | 84 | item("composer-collapse", "Collapse composer on scroll", Page::General, "composer rest resting scroll wheel conversation shrink minimize"), |
| 85 | 85 | item("send-shortcut", "Send shortcut", Page::General, "enter return ctrl multiline prompt new line composer"), |
| 86 | 86 | 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"), | |
| 87 | 88 | item("provider-update-checks", "Provider update checks", Page::General, "installed cli versions newer available claude pi jcode"), |
| 88 | 89 | item( |
| 89 | 90 | "continue-threads-after-server-update", |
| @@ -601,6 +601,9 @@ impl Workspace { | ||
| 601 | 601 | self.last_health = Instant::now(); |
| 602 | 602 | Catalog::refresh_all(cx); |
| 603 | 603 | } |
| 604 | + if allowed { | |
| 605 | + crate::ui::chat::refresh_limits(false, cx); | |
| 606 | + } | |
| 604 | 607 | if let Some((_, armed)) = self.settle_armed |
| 605 | 608 | && armed.elapsed() >= SETTLE_ARMED |
| 606 | 609 | { |
| @@ -784,6 +784,9 @@ pub fn format_cost(dollars: f64) -> String { | ||
| 784 | 784 | #[derive(Clone, Debug, PartialEq)] |
| 785 | 785 | pub struct Limit { |
| 786 | 786 | 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>, | |
| 787 | 790 | /// How much of it is used, 0–100. |
| 788 | 791 | pub percent: f64, |
| 789 | 792 | pub resets_at: Option<DateTime<Local>>, |
| @@ -814,6 +817,7 @@ pub fn limits_from_claude(reply: &Value) -> Vec<Limit> { | ||
| 814 | 817 | let percent = window.get("utilization").and_then(Value::as_f64)?; |
| 815 | 818 | Some(Limit { |
| 816 | 819 | label: label.to_string(), |
| 820 | + short: short_claude_label(key), | |
| 817 | 821 | percent, |
| 818 | 822 | resets_at: window.get("resets_at").and_then(parse_time).map(|at| at.with_timezone(&Local)), |
| 819 | 823 | severity: severity_of(percent).to_string(), |
| @@ -839,6 +843,7 @@ fn claude_limit(limit: &Value) -> Option<Limit> { | ||
| 839 | 843 | }; |
| 840 | 844 | Some(Limit { |
| 841 | 845 | label, |
| 846 | + short: short_claude_label(kind), | |
| 842 | 847 | percent, |
| 843 | 848 | resets_at: limit.get("resets_at").and_then(parse_time).map(|at| at.with_timezone(&Local)), |
| 844 | 849 | severity: limit |
| @@ -850,6 +855,15 @@ fn claude_limit(limit: &Value) -> Option<Limit> { | ||
| 850 | 855 | }) |
| 851 | 856 | } |
| 852 | 857 | |
| 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 | + | |
| 853 | 867 | fn severity_of(percent: f64) -> &'static str { |
| 854 | 868 | if percent >= 90. { |
| 855 | 869 | "critical" |
| @@ -870,6 +884,183 @@ fn humanize(key: &str) -> String { | ||
| 870 | 884 | } |
| 871 | 885 | } |
| 872 | 886 | |
| 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 | + | |
| 873 | 1064 | #[cfg(test)] |
| 874 | 1065 | mod tests { |
| 875 | 1066 | use super::*; |
| @@ -1033,6 +1224,52 @@ mod tests { | ||
| 1033 | 1224 | ); |
| 1034 | 1225 | } |
| 1035 | 1226 | |
| 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 | + | |
| 1036 | 1273 | #[test] |
| 1037 | 1274 | fn limits_from_named_windows() { |
| 1038 | 1275 | let reply = json!({"rate_limits": { |
| @@ -1043,6 +1280,7 @@ mod tests { | ||
| 1043 | 1280 | let limits = limits_from_claude(&reply); |
| 1044 | 1281 | assert_eq!(limits.len(), 2); |
| 1045 | 1282 | assert_eq!(limits[0].label, "Current session"); |
| 1283 | + assert_eq!(limits[0].short.as_deref(), Some("5h")); | |
| 1046 | 1284 | assert_eq!(limits[0].severity, "critical"); |
| 1047 | 1285 | assert_eq!(limits[1].label, "Weekly · all models"); |
| 1048 | 1286 | assert_eq!(limits[1].resets_at, None); |