diff --git a/codex-rs/core/src/openai_model_info.rs b/codex-rs/core/src/openai_model_info.rs index 0ae3f03eb..96f3ed77c 100644 --- a/codex-rs/core/src/openai_model_info.rs +++ b/codex-rs/core/src/openai_model_info.rs @@ -76,6 +76,8 @@ pub(crate) fn get_model_info(model_family: &ModelFamily) -> Option { _ if slug.starts_with("codex-") => Some(ModelInfo::new(CONTEXT_WINDOW_272K)), + _ if slug.starts_with("exp-") => Some(ModelInfo::new(CONTEXT_WINDOW_272K)), + _ => None, } } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 6b42e5134..466070e3a 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -113,6 +113,7 @@ pub(crate) struct ChatComposer { footer_mode: FooterMode, footer_hint_override: Option>, context_window_percent: Option, + context_window_used_tokens: Option, } /// Popup state – at most one can be visible at any time. @@ -156,6 +157,7 @@ impl ChatComposer { footer_mode: FooterMode::ShortcutSummary, footer_hint_override: None, context_window_percent: None, + context_window_used_tokens: None, }; // Apply configuration via the setter to keep side-effects centralized. this.set_disable_paste_burst(disable_paste_burst); @@ -1387,6 +1389,7 @@ impl ChatComposer { use_shift_enter_hint: self.use_shift_enter_hint, is_task_running: self.is_task_running, context_window_percent: self.context_window_percent, + context_window_used_tokens: self.context_window_used_tokens, } } @@ -1517,10 +1520,13 @@ impl ChatComposer { self.is_task_running = running; } - pub(crate) fn set_context_window_percent(&mut self, percent: Option) { - if self.context_window_percent != percent { - self.context_window_percent = percent; + pub(crate) fn set_context_window(&mut self, percent: Option, used_tokens: Option) { + if self.context_window_percent == percent && self.context_window_used_tokens == used_tokens + { + return; } + self.context_window_percent = percent; + self.context_window_used_tokens = used_tokens; } pub(crate) fn set_esc_backtrack_hint(&mut self, show: bool) { diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 79d7c60fa..11a97c783 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -1,6 +1,7 @@ use crate::key_hint; use crate::key_hint::KeyBinding; use crate::render::line_utils::prefix_lines; +use crate::status::format_tokens_compact; use crate::ui_consts::FOOTER_INDENT_COLS; use crossterm::event::KeyCode; use ratatui::buffer::Buffer; @@ -18,6 +19,7 @@ pub(crate) struct FooterProps { pub(crate) use_shift_enter_hint: bool, pub(crate) is_task_running: bool, pub(crate) context_window_percent: Option, + pub(crate) context_window_used_tokens: Option, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -81,7 +83,10 @@ fn footer_lines(props: FooterProps) -> Vec> { is_task_running: props.is_task_running, })], FooterMode::ShortcutSummary => { - let mut line = context_window_line(props.context_window_percent); + let mut line = context_window_line( + props.context_window_percent, + props.context_window_used_tokens, + ); line.push_span(" · ".dim()); line.extend(vec![ key_hint::plain(KeyCode::Char('?')).into(), @@ -94,7 +99,10 @@ fn footer_lines(props: FooterProps) -> Vec> { esc_backtrack_hint: props.esc_backtrack_hint, }), FooterMode::EscHint => vec![esc_hint_line(props.esc_backtrack_hint)], - FooterMode::ContextOnly => vec![context_window_line(props.context_window_percent)], + FooterMode::ContextOnly => vec![context_window_line( + props.context_window_percent, + props.context_window_used_tokens, + )], } } @@ -221,9 +229,18 @@ fn build_columns(entries: Vec>) -> Vec> { .collect() } -fn context_window_line(percent: Option) -> Line<'static> { - let percent = percent.unwrap_or(100).clamp(0, 100); - Line::from(vec![Span::from(format!("{percent}% context left")).dim()]) +fn context_window_line(percent: Option, used_tokens: Option) -> Line<'static> { + if let Some(percent) = percent { + let percent = percent.clamp(0, 100); + return Line::from(vec![Span::from(format!("{percent}% context left")).dim()]); + } + + if let Some(tokens) = used_tokens { + let used_fmt = format_tokens_compact(tokens); + return Line::from(vec![Span::from(format!("{used_fmt} used")).dim()]); + } + + Line::from(vec![Span::from("100% context left").dim()]) } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -400,6 +417,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, context_window_percent: None, + context_window_used_tokens: None, }, ); @@ -411,6 +429,7 @@ mod tests { use_shift_enter_hint: true, is_task_running: false, context_window_percent: None, + context_window_used_tokens: None, }, ); @@ -422,6 +441,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, context_window_percent: None, + context_window_used_tokens: None, }, ); @@ -433,6 +453,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: true, context_window_percent: None, + context_window_used_tokens: None, }, ); @@ -444,6 +465,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, context_window_percent: None, + context_window_used_tokens: None, }, ); @@ -455,6 +477,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, context_window_percent: None, + context_window_used_tokens: None, }, ); @@ -466,6 +489,19 @@ mod tests { use_shift_enter_hint: false, is_task_running: true, context_window_percent: Some(72), + context_window_used_tokens: None, + }, + ); + + snapshot_footer( + "footer_context_tokens_used", + FooterProps { + mode: FooterMode::ShortcutSummary, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: false, + context_window_percent: None, + context_window_used_tokens: Some(123_456), }, ); } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 5dbfb210b..b4255fd97 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -76,6 +76,7 @@ pub(crate) struct BottomPane { /// Queued user messages to show above the composer while a turn is running. queued_user_messages: QueuedUserMessages, context_window_percent: Option, + context_window_used_tokens: Option, } pub(crate) struct BottomPaneParams { @@ -118,6 +119,7 @@ impl BottomPane { esc_backtrack_hint: false, animations_enabled, context_window_percent: None, + context_window_used_tokens: None, } } @@ -130,6 +132,11 @@ impl BottomPane { self.context_window_percent } + #[cfg(test)] + pub(crate) fn context_window_used_tokens(&self) -> Option { + self.context_window_used_tokens + } + fn active_view(&self) -> Option<&dyn BottomPaneView> { self.view_stack.last().map(std::convert::AsRef::as_ref) } @@ -344,13 +351,16 @@ impl BottomPane { } } - pub(crate) fn set_context_window_percent(&mut self, percent: Option) { - if self.context_window_percent == percent { + pub(crate) fn set_context_window(&mut self, percent: Option, used_tokens: Option) { + if self.context_window_percent == percent && self.context_window_used_tokens == used_tokens + { return; } self.context_window_percent = percent; - self.composer.set_context_window_percent(percent); + self.context_window_used_tokens = used_tokens; + self.composer + .set_context_window(percent, self.context_window_used_tokens); self.request_redraw(); } diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_context_tokens_used.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_context_tokens_used.snap new file mode 100644 index 000000000..a77ca5565 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_context_tokens_used.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" 123K used · ? for shortcuts " diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 9ff5139a0..8bb1e78e2 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -515,7 +515,7 @@ impl ChatWidget { match info { Some(info) => self.apply_token_info(info), None => { - self.bottom_pane.set_context_window_percent(None); + self.bottom_pane.set_context_window(None, None); self.token_info = None; } } @@ -523,7 +523,8 @@ impl ChatWidget { fn apply_token_info(&mut self, info: TokenUsageInfo) { let percent = self.context_remaining_percent(&info); - self.bottom_pane.set_context_window_percent(percent); + let used_tokens = self.context_used_tokens(&info, percent.is_some()); + self.bottom_pane.set_context_window(percent, used_tokens); self.token_info = Some(info); } @@ -536,12 +537,20 @@ impl ChatWidget { }) } + fn context_used_tokens(&self, info: &TokenUsageInfo, percent_known: bool) -> Option { + if percent_known { + return None; + } + + Some(info.total_token_usage.tokens_in_context_window()) + } + fn restore_pre_review_token_info(&mut self) { if let Some(saved) = self.pre_review_token_info.take() { match saved { Some(info) => self.apply_token_info(info), None => { - self.bottom_pane.set_context_window_percent(None); + self.bottom_pane.set_context_window(None, None); self.token_info = None; } } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 53910916c..eb65e3a5d 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -297,6 +297,41 @@ fn token_count_none_resets_context_indicator() { assert_eq!(chat.bottom_pane.context_window_percent(), None); } +#[test] +fn context_indicator_shows_used_tokens_when_window_unknown() { + let (mut chat, _rx, _ops) = make_chatwidget_manual(); + + chat.config.model_context_window = None; + let auto_compact_limit = 200_000; + chat.config.model_auto_compact_token_limit = Some(auto_compact_limit); + + // No model window, so the indicator should fall back to showing tokens used. + let total_tokens = 106_000; + let token_usage = TokenUsage { + total_tokens, + ..TokenUsage::default() + }; + let token_info = TokenUsageInfo { + total_token_usage: token_usage.clone(), + last_token_usage: token_usage, + model_context_window: None, + }; + + chat.handle_codex_event(Event { + id: "token-usage".into(), + msg: EventMsg::TokenCount(TokenCountEvent { + info: Some(token_info), + rate_limits: None, + }), + }); + + assert_eq!(chat.bottom_pane.context_window_percent(), None); + assert_eq!( + chat.bottom_pane.context_window_used_tokens(), + Some(total_tokens) + ); +} + #[cfg_attr( target_os = "macos", ignore = "system configuration APIs are blocked under macOS seatbelt" diff --git a/codex-rs/tui/src/status/mod.rs b/codex-rs/tui/src/status/mod.rs index eccb6b72b..89c8daefd 100644 --- a/codex-rs/tui/src/status/mod.rs +++ b/codex-rs/tui/src/status/mod.rs @@ -5,6 +5,7 @@ mod helpers; mod rate_limits; pub(crate) use card::new_status_output; +pub(crate) use helpers::format_tokens_compact; pub(crate) use rate_limits::RateLimitSnapshotDisplay; pub(crate) use rate_limits::rate_limit_snapshot_display;