feat(tui): reland token activity command (#27925)

## Why

[#25345](https://github.com/openai/codex/pull/25345) was approved,
green, and squash-merged into its stacked base branch,
`fcoury/tokenmaxxing-api`. Four minutes later, that base branch was
force-pushed back to an API-only rebased head while preparing
[#25344](https://github.com/openai/codex/pull/25344) for `main`. As a
result, the squash commit from #25345 was orphaned and the TUI command
never reached `main` or a release.

This PR relands the orphaned TUI change from
[`411410b8`](https://github.com/openai/codex/commit/411410b85c2d8eb050d441f17396c5c4048d866f)
on current `main`.

## What changed

- Add `/usage`, `/usage daily`, `/usage weekly`, and `/usage cumulative`
for account token activity.
- Fetch account usage asynchronously through the existing
`account/usage/read` app-server RPC.
- Render daily, weekly, and cumulative activity with theme-aware
terminal palettes and bounded transient cards.
- Preserve transcript ordering while assistant streams, history
consolidations, active cells, and hooks complete.
- Hide `/usage` from completion when backend auth is unavailable while
keeping typed-command guidance.
- Carry current-main behavior forward for cwd-aware Markdown parsing,
Windows Terminal color detection, and personal access token auth.
- Clear pending usage cards on thread rollback and delay completed cards
until live hook output is committed.
- Add focused regression and snapshot coverage for loading, auth errors,
invalid views, rollback, hook ordering, layout, and charts.

## Prior review

The original implementation was approved by Eric Traut in #25345 after
testing multiple themes and light/dark terminals. This PR preserves that
reviewed implementation while adapting it to current `main` and adding
regression coverage for newer rollback and hook lifecycle behavior.

## Validation

- `just test -p codex-tui token_activity palette renderable
usage_command` — 37 passed.
- Focused rollback, hook-ordering, and error snapshot tests — 4 passed.
- `just fix -p codex-tui` — passed.
- `UV_CACHE_DIR=/private/tmp/codex-uv-cache just fmt` — passed.
- `cargo insta pending-snapshots` — no pending snapshots.
- `just test -p codex-tui` — 2,870 passed; two unrelated guardian
feature-flag tests failed because their expected `OverrideTurnContext`
event was absent:
-
`update_feature_flags_disabling_guardian_clears_manual_review_policy_without_history`
-
`update_feature_flags_disabling_guardian_clears_review_policy_and_restores_default`
- `just argument-comment-lint` could not complete because the local
Bazel LLVM `compiler-rt` repository is missing `include/sanitizer/*.h`.
The touched Rust diff was manually inspected and no missing
opaque-literal argument comments were found.
This commit is contained in:
Felipe Coury
2026-06-12 17:33:43 -07:00
committed by GitHub
Unverified
parent 9e07892253
commit c884536d84
42 changed files with 2296 additions and 57 deletions
+10 -2
View File
@@ -143,6 +143,7 @@ use codex_model_provider_info::ModelProviderInfo;
use codex_models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG;
use codex_models_manager::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG;
use codex_otel::SessionTelemetry;
use codex_otel::TelemetryAuthMode;
use codex_protocol::ThreadId;
use codex_protocol::config_types::Personality;
#[cfg(target_os = "windows")]
@@ -734,6 +735,7 @@ impl App {
initial_user_message,
enhanced_keys_supported: self.enhanced_keys_supported,
has_chatgpt_account: self.chat_widget.has_chatgpt_account(),
has_codex_backend_auth: self.chat_widget.has_codex_backend_auth(),
model_catalog: self.model_catalog.clone(),
feedback: self.feedback.clone(),
is_first_run: false,
@@ -822,6 +824,7 @@ impl App {
let feedback_audience = bootstrap.feedback_audience;
let auth_mode = bootstrap.auth_mode;
let has_chatgpt_account = bootstrap.has_chatgpt_account;
let has_codex_backend_auth = matches!(auth_mode, Some(TelemetryAuthMode::Chatgpt));
let requires_openai_auth = bootstrap.requires_openai_auth;
let status_account_display = bootstrap.status_account_display.clone();
let initial_plan_type = bootstrap.plan_type;
@@ -890,6 +893,7 @@ impl App {
),
enhanced_keys_supported,
has_chatgpt_account,
has_codex_backend_auth,
model_catalog: model_catalog.clone(),
feedback: feedback.clone(),
is_first_run,
@@ -925,6 +929,7 @@ impl App {
),
enhanced_keys_supported,
has_chatgpt_account,
has_codex_backend_auth,
model_catalog: model_catalog.clone(),
feedback: feedback.clone(),
is_first_run,
@@ -963,6 +968,7 @@ impl App {
),
enhanced_keys_supported,
has_chatgpt_account,
has_codex_backend_auth,
model_catalog: model_catalog.clone(),
feedback: feedback.clone(),
is_first_run,
@@ -1243,7 +1249,9 @@ See the Codex keymap documentation for supported actions and examples."
event: TuiEvent,
) -> Result<AppRunControl> {
let terminal_resize_reflow_enabled = self.terminal_resize_reflow_enabled();
if terminal_resize_reflow_enabled && matches!(event, TuiEvent::Draw | TuiEvent::Resize) {
if self.should_handle_draw_pre_render()
&& matches!(event, TuiEvent::Draw | TuiEvent::Resize)
{
self.handle_draw_pre_render(tui)?;
} else if matches!(event, TuiEvent::Draw | TuiEvent::Resize) {
let size = tui.terminal.size()?;
@@ -1322,7 +1330,7 @@ See the Codex keymap documentation for supported actions and examples."
self.disable_ambient_pet_before_shutdown(tui)?;
self.chat_widget.show_shutdown_in_progress();
let terminal_resize_reflow_enabled = self.terminal_resize_reflow_enabled();
if terminal_resize_reflow_enabled {
if self.should_handle_draw_pre_render() {
self.handle_draw_pre_render(tui)?;
}
self.chat_widget.pre_draw_tick();
+10
View File
@@ -80,6 +80,15 @@ impl App {
return;
}
ServerNotification::AccountUpdated(notification) => {
let has_codex_backend_auth = matches!(
notification.auth_mode,
Some(
AuthMode::Chatgpt
| AuthMode::ChatgptAuthTokens
| AuthMode::AgentIdentity
| AuthMode::PersonalAccessToken
)
);
self.chat_widget.update_account_state(
status_account_display_from_auth_mode(
notification.auth_mode,
@@ -89,6 +98,7 @@ impl App {
notification
.auth_mode
.is_some_and(AuthMode::has_chatgpt_account),
has_codex_backend_auth,
);
return;
}
@@ -24,6 +24,9 @@ use crate::hooks_rpc::write_hook_trust;
use crate::hooks_rpc::write_hook_trusts;
use codex_utils_absolute_path::AbsolutePathBuf;
const TOKEN_ACTIVITY_FETCH_TIMEOUT: std::time::Duration =
std::time::Duration::from_secs(/*secs*/ 15);
impl App {
pub(super) fn fetch_mcp_inventory(
&mut self,
@@ -78,6 +81,25 @@ impl App {
});
}
pub(super) fn refresh_token_activity(
&mut self,
app_server: &AppServerSession,
request_id: u64,
) {
let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
tokio::spawn(async move {
let result = tokio::time::timeout(
TOKEN_ACTIVITY_FETCH_TIMEOUT,
fetch_account_token_activity(request_handle),
)
.await
.map_err(|_| "account/usage/read timed out in TUI".to_string())
.and_then(|result| result.map_err(|err| err.to_string()));
app_event_tx.send(AppEvent::TokenActivityLoaded { request_id, result });
});
}
pub(super) fn send_add_credits_nudge_email(
&mut self,
app_server: &AppServerSession,
@@ -652,6 +674,19 @@ pub(super) async fn fetch_account_rate_limits(
Ok(app_server_rate_limit_snapshots(response))
}
pub(super) async fn fetch_account_token_activity(
request_handle: AppServerRequestHandle,
) -> Result<codex_app_server_protocol::GetAccountTokenUsageResponse> {
let request_id = RequestId::String(format!("account-token-usage-{}", Uuid::new_v4()));
request_handle
.request_typed(ClientRequest::GetAccountTokenUsage {
request_id,
params: None,
})
.await
.wrap_err("account/usage/read failed in TUI")
}
pub(super) async fn send_add_credits_nudge_email(
request_handle: AppServerRequestHandle,
credit_type: AddCreditsNudgeCreditType,
+34 -22
View File
@@ -227,27 +227,7 @@ impl App {
self.begin_thread_switch_history_replay_buffer();
}
AppEvent::InsertHistoryCell(cell) => {
let cell: Arc<dyn HistoryCell> = cell.into();
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
t.insert_cell(cell.clone());
tui.frame_requester().schedule_frame();
}
self.transcript_cells.push(cell.clone());
if self.initial_history_replay_buffer.as_ref().is_some() {
self.insert_history_cell_lines_with_initial_replay_buffer(
tui,
cell.as_ref(),
self.chat_widget
.history_wrap_width(tui.terminal.last_known_screen_size.width),
);
} else {
self.insert_history_cell_lines(
tui,
cell.as_ref(),
self.chat_widget
.history_wrap_width(tui.terminal.last_known_screen_size.width),
);
}
self.insert_history_cell(tui, cell);
}
AppEvent::EndInitialHistoryReplayBuffer => {
self.finish_initial_history_replay_buffer(tui);
@@ -265,10 +245,16 @@ impl App {
scrollback_reflow,
deferred_history_cell,
)?;
self.chat_widget.note_stream_consolidation_completed();
self.insert_completed_token_activity_output_after_stream_shutdown(tui);
}
AppEvent::ConsolidateProposedPlan(source) => {
if !self.terminal_resize_reflow_enabled() {
self.transcript_reflow.clear();
if !self.transcript_reflow.history_cell_refresh_requested() {
self.transcript_reflow.clear();
}
self.chat_widget.note_stream_consolidation_completed();
self.insert_completed_token_activity_output_after_stream_shutdown(tui);
return Ok(AppRunControl::Continue);
}
let end = self.transcript_cells.len();
@@ -303,6 +289,8 @@ impl App {
self.maybe_finish_stream_reflow(tui)?;
}
self.chat_widget.note_stream_consolidation_completed();
self.insert_completed_token_activity_output_after_stream_shutdown(tui);
}
AppEvent::ApplyThreadRollback { num_turns } => {
if self.apply_non_pending_thread_rollback(num_turns) {
@@ -722,6 +710,9 @@ impl App {
AppEvent::RefreshRateLimits { origin } => {
self.refresh_rate_limits(app_server, origin);
}
AppEvent::RefreshTokenActivity { request_id } => {
self.refresh_token_activity(app_server, request_id);
}
AppEvent::OpenThreadGoalMenu { thread_id } => {
self.open_thread_goal_menu(app_server, thread_id).await;
}
@@ -778,6 +769,25 @@ impl App {
}
}
},
AppEvent::TokenActivityLoaded { request_id, result } => {
if let Err(err) = &result {
tracing::warn!("account/usage/read failed during TUI refresh: {err}");
}
if self
.chat_widget
.finish_token_activity_refresh(request_id, result)
{
// Commit synchronously so an already queued /clear cannot overtake this card.
// Do not route through ChatWidget::add_to_history: /usage may complete during
// active work, and flushing an in-progress tool cell would corrupt its lifecycle.
// If an answer stream is active, keep the settled card transient until its
// provisional transcript cells have been consolidated.
self.insert_completed_token_activity_output_if_ready(tui);
}
}
AppEvent::CommitCompletedTokenActivityOutput => {
self.insert_completed_token_activity_output_after_stream_shutdown(tui);
}
AppEvent::ConnectorsLoaded { result, is_final } => {
self.chat_widget.on_connectors_loaded(result, is_final);
}
@@ -1966,6 +1976,7 @@ impl App {
}
self.sync_tui_theme_selection(name);
self.refresh_status_line();
tui.frame_requester().schedule_frame();
}
Err(err) => {
self.restore_runtime_theme_from_config();
@@ -1978,6 +1989,7 @@ impl App {
}
AppEvent::SyntaxThemePreviewed => {
self.refresh_status_line();
tui.frame_requester().schedule_frame();
}
AppEvent::OpenKeymapActionMenu { context, action } => {
self.chat_widget
+57
View File
@@ -8,6 +8,62 @@ use super::*;
const DESKTOP_THREAD_OPENED_MESSAGE: &str = "Opened this session in Codex Desktop.";
impl App {
pub(super) fn insert_history_cell(&mut self, tui: &mut tui::Tui, cell: Box<dyn HistoryCell>) {
let cell: Arc<dyn HistoryCell> = cell.into();
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
t.insert_cell(cell.clone());
tui.frame_requester().schedule_frame();
}
self.transcript_cells.push(cell.clone());
if self.initial_history_replay_buffer.as_ref().is_some() {
self.insert_history_cell_lines_with_initial_replay_buffer(
tui,
cell.as_ref(),
self.chat_widget
.history_wrap_width(tui.terminal.last_known_screen_size.width),
);
} else {
self.insert_history_cell_lines(
tui,
cell.as_ref(),
self.chat_widget
.history_wrap_width(tui.terminal.last_known_screen_size.width),
);
}
// A committed cell can unblock a settled /usage card that was waiting
// behind a transient active cell or a provisional stream tail.
self.chat_widget
.request_completed_token_activity_output_insertion();
}
pub(super) fn insert_completed_token_activity_output_if_ready(&mut self, tui: &mut tui::Tui) {
if self.chat_widget.token_activity_history_insertion_blocked()
|| self.transcript_cells.last().is_some_and(|cell| {
cell.as_any().is::<history_cell::AgentMessageCell>()
|| cell.as_any().is::<history_cell::ProposedPlanStreamCell>()
})
{
return;
}
self.insert_completed_token_activity_output(tui);
}
pub(super) fn insert_completed_token_activity_output(&mut self, tui: &mut tui::Tui) {
if let Some(cell) = self.chat_widget.take_completed_token_activity_output() {
self.insert_history_cell(tui, Box::new(cell));
}
}
pub(super) fn insert_completed_token_activity_output_after_stream_shutdown(
&mut self,
tui: &mut tui::Tui,
) {
if self.chat_widget.token_activity_history_insertion_blocked() {
return;
}
self.insert_completed_token_activity_output(tui);
}
pub(super) fn open_url_in_browser(&mut self, url: String) {
if let Err(err) = webbrowser::open(&url) {
self.chat_widget
@@ -110,6 +166,7 @@ impl App {
self.deferred_history_lines.clear();
self.has_emitted_history_lines = false;
self.transcript_reflow.clear();
self.chat_widget.clear_pending_token_activity_refreshes();
self.initial_history_replay_buffer = None;
self.backtrack = BacktrackState::default();
self.backtrack_render_pending = false;
+34 -3
View File
@@ -264,6 +264,14 @@ impl App {
/// transient stream rows.
pub(super) fn maybe_finish_stream_reflow(&mut self, tui: &mut tui::Tui) -> Result<()> {
if !self.terminal_resize_reflow_enabled() {
if self.transcript_reflow.take_stream_finish_reflow_needed() {
self.schedule_immediate_history_cell_refresh(tui);
self.maybe_run_resize_reflow(tui)?;
return Ok(());
}
if self.transcript_reflow.history_cell_refresh_requested() {
return Ok(());
}
self.transcript_reflow.clear();
return Ok(());
}
@@ -286,6 +294,22 @@ impl App {
tui.frame_requester().schedule_frame();
}
fn schedule_immediate_history_cell_refresh(&mut self, tui: &mut tui::Tui) {
self.transcript_reflow.schedule_history_cell_refresh();
tui.frame_requester().schedule_frame();
}
pub(crate) fn retry_pending_history_cell_refresh(&self, tui: &mut tui::Tui) {
if self.transcript_reflow.history_cell_refresh_requested() {
tui.frame_requester().schedule_frame();
}
}
pub(super) fn should_handle_draw_pre_render(&self) -> bool {
self.terminal_resize_reflow_enabled()
|| self.transcript_reflow.history_cell_refresh_requested()
}
/// Force stream-finalized output through the resize reflow path.
///
/// Proposed plan consolidation uses this stricter path because a completed plan is inserted or
@@ -293,7 +317,9 @@ impl App {
/// resize, the visible scrollback can keep the pre-consolidation wrapping.
pub(super) fn finish_required_stream_reflow(&mut self, tui: &mut tui::Tui) -> Result<()> {
if !self.terminal_resize_reflow_enabled() {
self.transcript_reflow.clear();
if !self.transcript_reflow.history_cell_refresh_requested() {
self.transcript_reflow.clear();
}
return Ok(());
}
self.schedule_immediate_resize_reflow(tui);
@@ -334,7 +360,10 @@ impl App {
} else {
frame_requester.schedule_frame_in(TRANSCRIPT_REFLOW_DEBOUNCE);
}
} else if !self.terminal_resize_reflow_enabled() && width.changed {
} else if !self.terminal_resize_reflow_enabled()
&& width.changed
&& !self.transcript_reflow.history_cell_refresh_requested()
{
self.transcript_reflow.clear();
}
}
@@ -388,7 +417,9 @@ impl App {
/// reuse terminal-wrapped output here would preserve exactly the stale wrapping this feature is
/// meant to remove.
pub(super) fn maybe_run_resize_reflow(&mut self, tui: &mut tui::Tui) -> Result<()> {
if !self.terminal_resize_reflow_enabled() {
if !self.terminal_resize_reflow_enabled()
&& !self.transcript_reflow.history_cell_refresh_requested()
{
self.transcript_reflow.clear();
return Ok(());
}
+50
View File
@@ -326,6 +326,7 @@ async fn enqueue_primary_thread_session_replays_turns_before_initial_prompt_subm
),
enhanced_keys_supported: false,
has_chatgpt_account: false,
has_codex_backend_auth: false,
model_catalog: app.model_catalog.clone(),
feedback: codex_feedback::CodexFeedback::new(),
is_first_run: false,
@@ -4594,6 +4595,32 @@ async fn height_shrink_schedules_resize_reflow() {
assert!(app.transcript_reflow.has_pending_reflow());
}
#[tokio::test]
async fn disabled_resize_reflow_preserves_pending_history_cell_refresh() {
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
let frame_requester = crate::tui::FrameRequester::test_dummy();
app.config
.features
.set_enabled(Feature::TerminalResizeReflow, /*enabled*/ false)
.expect("feature should be configurable");
assert!(!app.should_handle_draw_pre_render());
app.transcript_reflow.schedule_history_cell_refresh();
assert!(app.should_handle_draw_pre_render());
assert!(!app.handle_draw_size_change(
ratatui::layout::Size::new(/*width*/ 118, /*height*/ 35),
ratatui::layout::Size::new(/*width*/ 118, /*height*/ 35),
&frame_requester,
));
assert!(app.handle_draw_size_change(
ratatui::layout::Size::new(/*width*/ 119, /*height*/ 35),
ratatui::layout::Size::new(/*width*/ 118, /*height*/ 35),
&frame_requester,
));
assert!(app.transcript_reflow.history_cell_refresh_requested());
}
fn test_turn(turn_id: &str, status: TurnStatus, items: Vec<ThreadItem>) -> Turn {
Turn {
id: turn_id.to_string(),
@@ -5379,6 +5406,7 @@ async fn replace_chat_widget_reseeds_collab_agent_metadata_for_replay() {
initial_user_message: None,
enhanced_keys_supported: app.enhanced_keys_supported,
has_chatgpt_account: app.chat_widget.has_chatgpt_account(),
has_codex_backend_auth: app.chat_widget.has_codex_backend_auth(),
model_catalog: app.model_catalog.clone(),
feedback: app.feedback.clone(),
is_first_run: false,
@@ -5536,12 +5564,34 @@ async fn queued_rollback_syncs_overlay_and_clears_deferred_history() {
app.deferred_history_lines = vec![Line::from("stale buffered line").into()];
app.backtrack.overlay_preview_active = true;
app.backtrack.nth_user_message = 1;
app.chat_widget.update_account_state(
/*status_account_display*/ None, /*plan_type*/ None,
/*has_chatgpt_account*/ false, /*has_codex_backend_auth*/ true,
);
app.chat_widget
.set_composer_text("/usage".to_string(), Vec::new(), Vec::new());
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let pending_usage = app
.chat_widget
.active_cell_transcript_lines(/*width*/ 80)
.expect("pending usage transcript");
assert!(lines_to_single_string(&pending_usage).contains("Token activity\n Loading..."));
let changed = app.apply_non_pending_thread_rollback(/*num_turns*/ 1);
assert!(changed);
assert!(app.backtrack_render_pending);
assert!(app.deferred_history_lines.is_empty());
assert!(
app.chat_widget
.active_cell_transcript_lines(/*width*/ 80)
.is_none_or(|lines| !lines_to_single_string(&lines).contains("Token activity"))
);
assert_eq!(app.backtrack.nth_user_message, 0);
let user_messages: Vec<String> = app
.transcript_cells
+3
View File
@@ -294,6 +294,7 @@ impl App {
}
self.overlay = None;
self.backtrack.overlay_preview_active = false;
self.retry_pending_history_cell_refresh(tui);
if was_backtrack {
// Ensure backtrack state is fully reset when overlay closes (e.g. via 'q').
self.reset_backtrack_state();
@@ -539,6 +540,7 @@ impl App {
if !trim_transcript_cells_drop_last_n_user_turns(&mut self.transcript_cells, num_turns) {
return false;
}
self.chat_widget.clear_pending_token_activity_refreshes();
self.chat_widget
.truncate_agent_copy_history_to_user_turn_count(user_count(&self.transcript_cells));
self.sync_overlay_after_transcript_trim();
@@ -562,6 +564,7 @@ impl App {
&mut self.transcript_cells,
pending.selection.nth_user_message,
) {
self.chat_widget.clear_pending_token_activity_refreshes();
self.chat_widget
.truncate_agent_copy_history_to_user_turn_count(user_count(&self.transcript_cells));
self.sync_overlay_after_transcript_trim();
+15
View File
@@ -13,6 +13,7 @@ use std::path::PathBuf;
use codex_app_server_protocol::AddCreditsNudgeCreditType;
use codex_app_server_protocol::AddCreditsNudgeEmailStatus;
use codex_app_server_protocol::AppInfo;
use codex_app_server_protocol::GetAccountTokenUsageResponse;
use codex_app_server_protocol::MarketplaceAddResponse;
use codex_app_server_protocol::MarketplaceRemoveResponse;
use codex_app_server_protocol::MarketplaceUpgradeResponse;
@@ -293,6 +294,20 @@ pub(crate) enum AppEvent {
result: Result<Vec<RateLimitSnapshot>, String>,
},
/// Fetch account-wide token activity for a `/usage` history card.
RefreshTokenActivity {
request_id: u64,
},
/// Result of fetching account-wide token activity.
TokenActivityLoaded {
request_id: u64,
result: Result<GetAccountTokenUsageResponse, String>,
},
/// Commit a settled token activity card after a stream shutdown barrier.
CommitCompletedTokenActivityOutput,
/// Send a user-confirmed request to notify the workspace owner.
SendAddCreditsNudgeEmail {
credit_type: AddCreditsNudgeCreditType,
@@ -373,6 +373,7 @@ pub(crate) struct ChatComposer {
config: ChatComposerConfig,
connectors_enabled: bool,
plugins_command_enabled: bool,
token_activity_command_enabled: bool,
service_tier_commands_enabled: bool,
service_tier_commands: Vec<ServiceTierCommand>,
mentions_v2_enabled: bool,
@@ -441,6 +442,7 @@ impl ChatComposer {
collaboration_modes_enabled: self.collaboration_modes_enabled,
connectors_enabled: self.connectors_enabled,
plugins_command_enabled: self.plugins_command_enabled,
token_activity_command_enabled: self.token_activity_command_enabled,
service_tier_commands_enabled: self.service_tier_commands_enabled,
goal_command_enabled: self.goal_command_enabled,
personality_command_enabled: self.personality_command_enabled,
@@ -535,6 +537,7 @@ impl ChatComposer {
config,
connectors_enabled: false,
plugins_command_enabled: false,
token_activity_command_enabled: false,
service_tier_commands_enabled: false,
service_tier_commands: Vec::new(),
mentions_v2_enabled: false,
@@ -577,6 +580,10 @@ impl ChatComposer {
self.plugins_command_enabled = enabled;
}
pub fn set_token_activity_command_enabled(&mut self, enabled: bool) {
self.token_activity_command_enabled = enabled;
}
pub fn set_mentions_v2_enabled(&mut self, enabled: bool) {
self.mentions_v2_enabled = enabled;
self.history.set_at_mention_restore_enabled(enabled);
@@ -174,6 +174,7 @@ impl<'a> SlashInput<'a> {
collaboration_modes_enabled: self.command_flags.collaboration_modes_enabled,
connectors_enabled: self.command_flags.connectors_enabled,
plugins_command_enabled: self.command_flags.plugins_command_enabled,
token_activity_command_enabled: self.command_flags.token_activity_command_enabled,
service_tier_commands_enabled: self.command_flags.service_tier_commands_enabled,
goal_command_enabled: self.command_flags.goal_command_enabled,
personality_command_enabled: self.command_flags.personality_command_enabled,
@@ -44,6 +44,7 @@ pub(crate) struct CommandPopupFlags {
pub(crate) collaboration_modes_enabled: bool,
pub(crate) connectors_enabled: bool,
pub(crate) plugins_command_enabled: bool,
pub(crate) token_activity_command_enabled: bool,
pub(crate) service_tier_commands_enabled: bool,
pub(crate) goal_command_enabled: bool,
pub(crate) personality_command_enabled: bool,
@@ -57,6 +58,7 @@ impl From<CommandPopupFlags> for BuiltinCommandFlags {
collaboration_modes_enabled: value.collaboration_modes_enabled,
connectors_enabled: value.connectors_enabled,
plugins_command_enabled: value.plugins_command_enabled,
token_activity_command_enabled: value.token_activity_command_enabled,
service_tier_commands_enabled: value.service_tier_commands_enabled,
goal_command_enabled: value.goal_command_enabled,
personality_command_enabled: value.personality_command_enabled,
@@ -529,6 +531,7 @@ mod tests {
collaboration_modes_enabled: true,
connectors_enabled: false,
plugins_command_enabled: false,
token_activity_command_enabled: false,
service_tier_commands_enabled: false,
goal_command_enabled: false,
personality_command_enabled: true,
@@ -555,6 +558,7 @@ mod tests {
collaboration_modes_enabled: true,
connectors_enabled: false,
plugins_command_enabled: false,
token_activity_command_enabled: false,
service_tier_commands_enabled: false,
goal_command_enabled: false,
personality_command_enabled: false,
@@ -586,6 +590,7 @@ mod tests {
collaboration_modes_enabled: true,
connectors_enabled: false,
plugins_command_enabled: false,
token_activity_command_enabled: false,
service_tier_commands_enabled: false,
goal_command_enabled: false,
personality_command_enabled: true,
+5
View File
@@ -326,6 +326,11 @@ impl BottomPane {
self.request_redraw();
}
pub fn set_token_activity_command_enabled(&mut self, enabled: bool) {
self.composer.set_token_activity_command_enabled(enabled);
self.request_redraw();
}
pub fn set_mentions_v2_enabled(&mut self, enabled: bool) {
self.composer.set_mentions_v2_enabled(enabled);
self.request_redraw();
+30 -2
View File
@@ -58,6 +58,7 @@ pub(crate) struct BuiltinCommandFlags {
pub(crate) collaboration_modes_enabled: bool,
pub(crate) connectors_enabled: bool,
pub(crate) plugins_command_enabled: bool,
pub(crate) token_activity_command_enabled: bool,
pub(crate) service_tier_commands_enabled: bool,
pub(crate) goal_command_enabled: bool,
pub(crate) personality_command_enabled: bool,
@@ -73,6 +74,7 @@ pub(crate) fn builtins_for_input(flags: BuiltinCommandFlags) -> Vec<(&'static st
.filter(|(_, cmd)| flags.collaboration_modes_enabled || *cmd != SlashCommand::Plan)
.filter(|(_, cmd)| flags.connectors_enabled || *cmd != SlashCommand::Apps)
.filter(|(_, cmd)| flags.plugins_command_enabled || *cmd != SlashCommand::Plugins)
.filter(|(_, cmd)| flags.token_activity_command_enabled || *cmd != SlashCommand::Usage)
.filter(|(_, cmd)| flags.goal_command_enabled || *cmd != SlashCommand::Goal)
.filter(|(_, cmd)| flags.personality_command_enabled || *cmd != SlashCommand::Personality)
.filter(|(_, cmd)| !flags.side_conversation_active || cmd.available_in_side_conversation())
@@ -104,8 +106,9 @@ pub(crate) fn commands_for_input(
/// Find a single built-in command by a recognized name or alias, after applying feature gating.
///
/// Side-conversation gating is intentionally enforced by dispatch rather than command lookup so a
/// typed command can produce a side-specific unavailable message while the popup still hides it.
/// Side-conversation and token-activity gating are intentionally enforced by dispatch rather than
/// command lookup so a typed command can produce a specific unavailable message while the popup
/// still hides it.
pub(crate) fn find_builtin_command(name: &str, flags: BuiltinCommandFlags) -> Option<SlashCommand> {
let cmd = SlashCommand::from_str(name).ok().or_else(|| {
let repeated_os = name.strip_prefix('g')?.strip_suffix("al")?;
@@ -113,6 +116,7 @@ pub(crate) fn find_builtin_command(name: &str, flags: BuiltinCommandFlags) -> Op
.then_some(SlashCommand::Goal)
})?;
builtins_for_input(BuiltinCommandFlags {
token_activity_command_enabled: true,
side_conversation_active: false,
..flags
})
@@ -163,6 +167,7 @@ mod tests {
collaboration_modes_enabled: true,
connectors_enabled: true,
plugins_command_enabled: true,
token_activity_command_enabled: true,
service_tier_commands_enabled: true,
goal_command_enabled: true,
personality_command_enabled: true,
@@ -262,6 +267,28 @@ mod tests {
assert_eq!(find_builtin_command("goal", flags), None);
}
#[test]
fn usage_command_is_hidden_from_input_when_account_token_activity_is_disabled() {
let mut flags = all_enabled_flags();
flags.token_activity_command_enabled = false;
assert_eq!(
builtins_for_input(flags)
.into_iter()
.find(|(_, command)| *command == SlashCommand::Usage),
None
);
}
#[test]
fn usage_command_exact_lookup_still_resolves_when_account_token_activity_is_disabled() {
let mut flags = all_enabled_flags();
flags.token_activity_command_enabled = false;
assert_eq!(
find_builtin_command("usage", flags),
Some(SlashCommand::Usage)
);
}
#[test]
fn side_conversation_hides_commands_without_side_flag() {
let commands = builtins_for_input(BuiltinCommandFlags {
@@ -281,6 +308,7 @@ mod tests {
SlashCommand::Diff,
SlashCommand::Mention,
SlashCommand::Status,
SlashCommand::Usage,
]
);
}
+22 -5
View File
@@ -407,6 +407,7 @@ mod status_controls;
mod status_surfaces;
mod streaming;
use self::status_surfaces::CachedProjectRootName;
mod tokens;
mod tool_lifecycle;
mod tool_requests;
mod transcript;
@@ -483,6 +484,7 @@ pub(crate) struct ChatWidgetInit {
pub(crate) initial_user_message: Option<UserMessage>,
pub(crate) enhanced_keys_supported: bool,
pub(crate) has_chatgpt_account: bool,
pub(crate) has_codex_backend_auth: bool,
pub(crate) model_catalog: Arc<ModelCatalog>,
pub(crate) feedback: codex_feedback::CodexFeedback,
pub(crate) is_first_run: bool,
@@ -534,6 +536,7 @@ pub(crate) struct ChatWidget {
/// The currently active collaboration mask, if any.
active_collaboration_mask: Option<CollaborationModeMask>,
has_chatgpt_account: bool,
has_codex_backend_auth: bool,
model_catalog: Arc<ModelCatalog>,
session_telemetry: SessionTelemetry,
session_header: SessionHeader,
@@ -545,6 +548,9 @@ pub(crate) struct ChatWidget {
rate_limit_snapshots_by_limit_id: BTreeMap<String, RateLimitSnapshotDisplay>,
refreshing_status_outputs: Vec<(u64, StatusHistoryHandle)>,
next_status_refresh_request_id: u64,
refreshing_token_activity_output: Option<tokens::PendingTokenActivityOutput>,
completed_token_activity_output: Option<history_cell::CompositeHistoryCell>,
next_token_activity_request_id: u64,
plan_type: Option<PlanType>,
codex_rate_limit_reached_type: Option<RateLimitReachedType>,
rate_limit_warnings: RateLimitWarningState,
@@ -556,6 +562,7 @@ pub(crate) struct ChatWidget {
stream_controller: Option<StreamController>,
// Stream lifecycle controller for proposed plan output.
plan_stream_controller: Option<PlanStreamController>,
pending_stream_consolidations: usize,
/// Holds the platform clipboard lease so copied text remains available while supported.
clipboard_lease: Option<crate::clipboard_copy::ClipboardLease>,
copy_last_response_binding: Vec<KeyBinding>,
@@ -1168,6 +1175,7 @@ impl ChatWidget {
if let Some(active) = self.transcript.active_cell.take() {
self.transcript.needs_final_message_separator = true;
self.app_event_tx.send(AppEvent::InsertHistoryCell(active));
self.request_completed_token_activity_output_insertion();
}
}
@@ -1382,6 +1390,7 @@ impl ChatWidget {
tool.mark_failed();
}
self.add_boxed_history(cell);
self.request_completed_token_activity_output_insertion();
}
}
@@ -1870,12 +1879,12 @@ impl ChatWidget {
self.current_rollout_path.clone()
}
/// Returns a cache key describing the current in-flight active cell for the transcript overlay.
/// Returns a cache key describing the current in-flight cells for the transcript overlay.
///
/// `Ctrl+T` renders committed transcript cells plus a render-only live tail derived from the
/// current active cell, and the overlay caches that tail; this key is what it uses to decide
/// whether it must recompute. When there is no active cell, this returns `None` so the overlay
/// can drop the tail entirely.
/// current active, hook, and token activity cells, and the overlay caches that tail; this key is
/// what it uses to decide whether it must recompute. When there are no live cells, this returns
/// `None` so the overlay can drop the tail entirely.
///
/// If callers mutate the active cell's transcript output without bumping the revision (or
/// providing an appropriate animation tick), the overlay will keep showing a stale tail while
@@ -1883,7 +1892,8 @@ impl ChatWidget {
pub(crate) fn active_cell_transcript_key(&self) -> Option<ActiveCellTranscriptKey> {
let cell = self.transcript.active_cell.as_ref();
let hook_cell = self.active_hook_cell.as_ref();
if cell.is_none() && hook_cell.is_none() {
let token_activity_cell = self.pending_token_activity_output();
if cell.is_none() && hook_cell.is_none() && token_activity_cell.is_none() {
return None;
}
Some(ActiveCellTranscriptKey {
@@ -1921,6 +1931,13 @@ impl ChatWidget {
}
lines.extend(hook_lines);
}
if let Some(token_activity_cell) = self.pending_token_activity_output() {
let token_activity_lines = token_activity_cell.transcript_hyperlink_lines(width);
if !token_activity_lines.is_empty() && !lines.is_empty() {
lines.push(HyperlinkLine::from(""));
}
lines.extend(token_activity_lines);
}
(!lines.is_empty()).then_some(lines)
}
@@ -19,6 +19,7 @@ impl ChatWidget {
initial_user_message,
enhanced_keys_supported,
has_chatgpt_account,
has_codex_backend_auth,
model_catalog,
feedback,
is_first_run,
@@ -113,6 +114,7 @@ impl ChatWidget {
current_collaboration_mode,
active_collaboration_mask,
has_chatgpt_account,
has_codex_backend_auth,
model_catalog,
session_telemetry,
session_header: SessionHeader::new(header_model),
@@ -124,6 +126,9 @@ impl ChatWidget {
rate_limit_snapshots_by_limit_id: BTreeMap::new(),
refreshing_status_outputs: Vec::new(),
next_status_refresh_request_id: 0,
refreshing_token_activity_output: None,
completed_token_activity_output: None,
next_token_activity_request_id: 0,
plan_type: initial_plan_type,
codex_rate_limit_reached_type: None,
rate_limit_warnings: RateLimitWarningState::default(),
@@ -133,6 +138,7 @@ impl ChatWidget {
adaptive_chunking: AdaptiveChunkingPolicy::default(),
stream_controller: None,
plan_stream_controller: None,
pending_stream_consolidations: 0,
clipboard_lease: None,
copy_last_response_binding,
running_commands: HashMap::new(),
@@ -255,6 +261,9 @@ impl ChatWidget {
widget
.bottom_pane
.set_connectors_enabled(widget.connectors_enabled());
widget
.bottom_pane
.set_token_activity_command_enabled(widget.has_codex_backend_auth);
widget.refresh_status_surfaces();
widget
@@ -10,6 +10,7 @@ impl ChatWidget {
pub(super) fn clear_active_hook_cell(&mut self) {
if self.active_hook_cell.take().is_some() {
self.bump_active_cell_revision();
self.request_completed_token_activity_output_insertion();
}
}
@@ -84,6 +85,7 @@ impl ChatWidget {
self.transcript.needs_final_message_separator = true;
self.app_event_tx
.send(AppEvent::InsertHistoryCell(Box::new(completed_cell)));
self.request_completed_token_activity_output_insertion();
}
pub(super) fn finish_active_hook_cell_if_idle(&mut self) {
@@ -93,6 +95,7 @@ impl ChatWidget {
if cell.is_empty() {
self.active_hook_cell = None;
self.bump_active_cell_revision();
self.request_completed_token_activity_output_insertion();
return;
}
if cell.should_flush()
@@ -102,6 +105,7 @@ impl ChatWidget {
self.transcript.needs_final_message_separator = true;
self.app_event_tx
.send(AppEvent::InsertHistoryCell(Box::new(cell)));
self.request_completed_token_activity_output_insertion();
}
}
+10
View File
@@ -26,6 +26,16 @@ impl ChatWidget {
let mut flex = FlexRenderable::new();
flex.push(/*flex*/ 1, active_cell_renderable);
flex.push(/*flex*/ 0, active_hook_cell_renderable);
if let Some(cell) = self.pending_token_activity_output() {
flex.push(
/*flex*/ 1,
RenderableItem::Owned(Box::new(TranscriptAreaRenderable {
child: cell,
top: 1,
right: active_cell_right_reserve,
})),
);
}
flex.push(
/*flex*/ 0,
RenderableItem::Owned(Box::new(BottomPaneComposerReserveRenderable {
+14
View File
@@ -206,17 +206,31 @@ impl ChatWidget {
self.has_chatgpt_account
}
pub(crate) fn has_codex_backend_auth(&self) -> bool {
self.has_codex_backend_auth
}
pub(crate) fn update_account_state(
&mut self,
status_account_display: Option<StatusAccountDisplay>,
plan_type: Option<PlanType>,
has_chatgpt_account: bool,
has_codex_backend_auth: bool,
) {
let account_state_changed = self.status_account_display != status_account_display
|| self.has_chatgpt_account != has_chatgpt_account
|| self.has_codex_backend_auth != has_codex_backend_auth;
if account_state_changed {
self.clear_pending_token_activity_refreshes();
}
self.status_account_display = status_account_display;
self.plan_type = plan_type;
self.has_chatgpt_account = has_chatgpt_account;
self.has_codex_backend_auth = has_codex_backend_auth;
self.bottom_pane
.set_connectors_enabled(self.connectors_enabled());
self.bottom_pane
.set_token_activity_command_enabled(has_codex_backend_auth);
}
/// Set the syntax theme override in the widget's config copy.
@@ -36,6 +36,7 @@ const SIDE_SLASH_COMMAND_UNAVAILABLE_HINT: &str =
"Press Ctrl+C to return to the main thread first.";
const GOAL_USAGE_HINT: &str = "Example: /goal improve benchmark coverage";
const RAW_USAGE: &str = "Usage: /raw [on|off]";
const USAGE_CHATGPT_LOGIN_REQUIRED: &str = "Sign in with ChatGPT to use /usage.";
impl ChatWidget {
/// Dispatch a bare slash command and record its staged local-history entry.
@@ -432,6 +433,11 @@ impl ChatWidget {
);
}
}
SlashCommand::Usage => {
if self.ensure_token_activity_command_available() {
self.add_token_activity_output(tokens::TokenActivityView::Daily);
}
}
SlashCommand::Ide => {
self.handle_ide_command();
}
@@ -651,6 +657,16 @@ impl ChatWidget {
} = prepared;
let trimmed = args.trim();
match cmd {
SlashCommand::Usage => {
if self.ensure_token_activity_command_available() {
match tokens::TokenActivityView::parse(trimmed) {
Some(view) => self.add_token_activity_output(view),
None => self.add_error_message(
"Usage: /usage [daily|weekly|cumulative]".to_string(),
),
}
}
}
SlashCommand::Ide => {
self.handle_ide_command_args(trimmed);
}
@@ -992,6 +1008,7 @@ impl ChatWidget {
collaboration_modes_enabled: self.collaboration_modes_enabled(),
connectors_enabled: self.connectors_enabled(),
plugins_command_enabled: self.config.features.enabled(Feature::Plugins),
token_activity_command_enabled: self.has_codex_backend_auth,
goal_command_enabled: self.config.features.enabled(Feature::Goals),
service_tier_commands_enabled: self.fast_mode_enabled(),
personality_command_enabled: self.config.features.enabled(Feature::Personality),
@@ -1000,6 +1017,14 @@ impl ChatWidget {
}
}
fn ensure_token_activity_command_available(&mut self) -> bool {
if self.has_codex_backend_auth {
return true;
}
self.add_error_message(USAGE_CHATGPT_LOGIN_REQUIRED.to_string());
false
}
fn queued_command_drain_result(&self, cmd: SlashCommand) -> QueueDrain {
if self.is_user_turn_pending_or_running() || !self.bottom_pane.no_modal_or_popup_active() {
return QueueDrain::Stop;
@@ -1007,6 +1032,7 @@ impl ChatWidget {
match cmd {
SlashCommand::Ide
| SlashCommand::Status
| SlashCommand::Usage
| SlashCommand::DebugConfig
| SlashCommand::Ps
| SlashCommand::Stop
@@ -0,0 +1,14 @@
---
source: tui/src/chatwidget/tests/slash_commands.rs
expression: normalize_snapshot_paths(term.backend().vt100().screen().contents())
---
/usage daily
Token activity
Loading...
Ask Codex to do anything
gpt-5.5 default · /tmp/project
@@ -0,0 +1,5 @@
---
source: tui/src/chatwidget/tests/slash_commands.rs
expression: rendered
---
■ Sign in with ChatGPT to use /usage.
@@ -0,0 +1,5 @@
---
source: tui/src/chatwidget/tests/slash_commands.rs
expression: rendered
---
■ Usage: /usage [daily|weekly|cumulative]
+7
View File
@@ -40,6 +40,7 @@ impl ChatWidget {
if let Some(source) = source {
let source =
parse_assistant_markdown(&source, self.config.cwd.as_path()).visible_markdown;
self.note_stream_consolidation_queued();
self.app_event_tx.send(AppEvent::ConsolidateAgentMessage {
source,
cwd: self.config.cwd.to_path_buf(),
@@ -52,6 +53,9 @@ impl ChatWidget {
if had_stream_controller && self.stream_controllers_idle() {
self.app_event_tx.send(AppEvent::StopCommitAnimation);
}
if had_stream_controller {
self.request_completed_token_activity_output_insertion();
}
}
pub(super) fn stream_controllers_idle(&self) -> bool {
@@ -175,18 +179,21 @@ impl ChatWidget {
// TODO: Replace streamed output with the final plan item text if plan streaming is
// removed or if we need to reconcile mismatches between streamed and final content.
if let Some(source) = consolidated_plan_source {
self.note_stream_consolidation_queued();
self.app_event_tx
.send(AppEvent::ConsolidateProposedPlan(source));
}
} else if !plan_text.is_empty() {
self.add_to_history(history_cell::new_proposed_plan(plan_text, &self.config.cwd));
} else if let Some(source) = consolidated_plan_source {
self.note_stream_consolidation_queued();
self.app_event_tx
.send(AppEvent::ConsolidateProposedPlan(source));
}
if should_restore_after_stream {
self.status_state.pending_status_indicator_restore = true;
self.maybe_restore_status_indicator_after_stream_idle();
self.request_completed_token_activity_output_insertion();
}
}
+20 -1
View File
@@ -147,6 +147,23 @@ pub(super) async fn make_chatwidget_manual(
ChatWidget,
tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
tokio::sync::mpsc::UnboundedReceiver<Op>,
) {
make_chatwidget_manual_with_auth(
model_override,
/*has_chatgpt_account*/ false,
/*has_codex_backend_auth*/ false,
)
.await
}
pub(super) async fn make_chatwidget_manual_with_auth(
model_override: Option<&str>,
has_chatgpt_account: bool,
has_codex_backend_auth: bool,
) -> (
ChatWidget,
tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
tokio::sync::mpsc::UnboundedReceiver<Op>,
) {
let (tx_raw, rx) = unbounded_channel::<AppEvent>();
let app_event_tx = AppEventSender::new(tx_raw);
@@ -167,7 +184,8 @@ pub(super) async fn make_chatwidget_manual(
workspace_command_runner: None,
initial_user_message: None,
enhanced_keys_supported: false,
has_chatgpt_account: false,
has_chatgpt_account,
has_codex_backend_auth,
model_catalog,
feedback: codex_feedback::CodexFeedback::new(),
is_first_run: true,
@@ -228,6 +246,7 @@ pub(super) fn assert_no_submit_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiv
pub(crate) fn set_chatgpt_auth(chat: &mut ChatWidget) {
chat.has_chatgpt_account = true;
chat.has_codex_backend_auth = true;
chat.model_catalog = test_model_catalog(&chat.config);
}
@@ -1494,6 +1494,7 @@ async fn make_startup_chat_with_cli_overrides(
initial_user_message: None,
enhanced_keys_supported: false,
has_chatgpt_account: false,
has_codex_backend_auth: false,
model_catalog: test_model_catalog(&cfg),
feedback: codex_feedback::CodexFeedback::new(),
is_first_run: true,
@@ -38,6 +38,7 @@ async fn experimental_mode_plan_is_ignored_on_startup() {
initial_user_message: None,
enhanced_keys_supported: false,
has_chatgpt_account: false,
has_codex_backend_auth: false,
model_catalog: test_model_catalog(&cfg),
feedback: codex_feedback::CodexFeedback::new(),
is_first_run: true,
@@ -79,6 +79,21 @@ fn recall_latest_after_clearing(chat: &mut ChatWidget) -> String {
chat.bottom_pane.composer_text()
}
fn dispatch_usage_and_expect_refresh(
chat: &mut ChatWidget,
rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
) -> u64 {
chat.dispatch_command(SlashCommand::Usage);
expect_token_activity_refresh(rx)
}
fn expect_token_activity_refresh(rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>) -> u64 {
match rx.try_recv() {
Ok(AppEvent::RefreshTokenActivity { request_id }) => request_id,
other => panic!("expected token activity refresh request, got {other:?}"),
}
}
fn next_add_to_history_event(rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>) -> String {
loop {
match rx.try_recv() {
@@ -1168,6 +1183,397 @@ async fn usage_error_slash_command_is_available_from_local_recall() {
assert_eq!(recall_latest_after_clearing(&mut chat), "/raw maybe");
}
#[tokio::test]
async fn signed_out_usage_command_reports_chatgpt_login_requirement() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
submit_composer_text(&mut chat, "/usage");
let cells = drain_insert_history(&mut rx);
let rendered = cells
.iter()
.map(|cell| lines_to_single_string(cell))
.collect::<Vec<_>>()
.join("\n");
assert_chatwidget_snapshot!(
"signed_out_usage_command_reports_chatgpt_login_requirement",
rendered
);
assert_eq!(recall_latest_after_clearing(&mut chat), "/usage");
}
#[tokio::test]
async fn signed_out_usage_command_with_args_reports_chatgpt_login_requirement() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
submit_composer_text(&mut chat, "/usage weekly");
let cells = drain_insert_history(&mut rx);
let rendered = cells
.iter()
.map(|cell| lines_to_single_string(cell))
.collect::<Vec<_>>()
.join("\n");
assert!(
rendered.contains("Sign in with ChatGPT to use /usage."),
"expected ChatGPT login requirement, got: {rendered:?}"
);
assert_eq!(recall_latest_after_clearing(&mut chat), "/usage weekly");
}
#[tokio::test]
async fn usage_command_with_invalid_view_reports_usage_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
submit_composer_text(&mut chat, "/usage monthly");
let rendered = drain_insert_history(&mut rx)
.iter()
.map(|cell| lines_to_single_string(cell))
.collect::<Vec<_>>()
.join("\n");
assert_chatwidget_snapshot!("usage_command_with_invalid_view_reports_usage", rendered);
assert_eq!(recall_latest_after_clearing(&mut chat), "/usage monthly");
}
#[tokio::test]
async fn usage_command_runs_with_backend_auth_without_chatgpt_account_flag() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.update_account_state(
/*status_account_display*/ None, /*plan_type*/ None,
/*has_chatgpt_account*/ false, /*has_codex_backend_auth*/ true,
);
chat.dispatch_command(SlashCommand::Usage);
assert_matches!(rx.try_recv(), Ok(AppEvent::RefreshTokenActivity { .. }));
assert!(!chat.has_chatgpt_account());
}
#[tokio::test]
async fn usage_command_runs_with_backend_auth_from_widget_init() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual_with_auth(
/*model_override*/ None, /*has_chatgpt_account*/ false,
/*has_codex_backend_auth*/ true,
)
.await;
chat.dispatch_command(SlashCommand::Usage);
assert_matches!(rx.try_recv(), Ok(AppEvent::RefreshTokenActivity { .. }));
assert!(!chat.has_chatgpt_account());
assert!(chat.has_codex_backend_auth());
}
#[tokio::test]
async fn clearing_pending_token_activity_refreshes_discards_late_result() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
let request_id = dispatch_usage_and_expect_refresh(&mut chat, &mut rx);
assert_eq!(
chat.pending_token_activity_output()
.map(|cell| lines_to_single_string(&cell.display_lines(u16::MAX))),
Some("/usage daily\n\n Token activity\n Loading...\n".to_string()),
);
assert_eq!(
chat.active_cell_transcript_lines(u16::MAX)
.map(|lines| lines_to_single_string(&lines)),
Some("/usage daily\n\n Token activity\n Loading...\n".to_string()),
);
chat.clear_pending_token_activity_refreshes();
assert!(
!chat.finish_token_activity_refresh(
request_id,
Err("stale token activity result".to_string()),
)
);
}
#[tokio::test]
async fn account_state_change_discards_pending_token_activity_refresh() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
let request_id = dispatch_usage_and_expect_refresh(&mut chat, &mut rx);
assert!(chat.pending_token_activity_output().is_some());
chat.update_account_state(
Some(crate::status::StatusAccountDisplay::ChatGpt {
email: Some("new-account@example.com".to_string()),
plan: None,
}),
/*plan_type*/ None,
/*has_chatgpt_account*/ true,
/*has_codex_backend_auth*/ true,
);
assert!(chat.pending_token_activity_output().is_none());
assert!(
!chat.finish_token_activity_refresh(
request_id,
Err("stale token activity result".to_string()),
)
);
}
#[tokio::test]
async fn pending_token_activity_refresh_renders_above_composer_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
dispatch_usage_and_expect_refresh(&mut chat, &mut rx);
let width: u16 = 80;
let height = chat.desired_height(width);
let backend = VT100Backend::new(width, height);
let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal");
term.set_viewport_area(Rect::new(/*x*/ 0, /*y*/ 0, width, height));
term.draw(|f| {
chat.render(f.area(), f.buffer_mut());
})
.unwrap();
assert_chatwidget_snapshot!(
"pending_token_activity_refresh_renders_above_composer_snapshot",
normalize_snapshot_paths(term.backend().vt100().screen().contents())
);
}
#[tokio::test]
async fn completed_token_activity_refresh_returns_one_history_cell() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
let request_id = dispatch_usage_and_expect_refresh(&mut chat, &mut rx);
assert!(chat.pending_token_activity_output().is_some());
assert!(
chat.finish_token_activity_refresh(
request_id,
Err("token activity unavailable".to_string()),
)
);
assert!(chat.pending_token_activity_output().is_some());
let cell = chat
.take_completed_token_activity_output()
.expect("completed token activity cell");
assert!(chat.pending_token_activity_output().is_none());
assert_eq!(
lines_to_single_string(&cell.display_lines(u16::MAX)),
"/usage daily\n\n Token activity\n Token activity unavailable\n",
);
}
#[tokio::test]
async fn completed_token_activity_refresh_waits_for_active_stream() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
let request_id = dispatch_usage_and_expect_refresh(&mut chat, &mut rx);
chat.on_agent_message_delta("partial response".to_string());
assert!(chat.token_activity_history_insertion_blocked());
assert!(
chat.finish_token_activity_refresh(
request_id,
Err("token activity unavailable".to_string()),
)
);
assert_eq!(
chat.pending_token_activity_output()
.map(|cell| lines_to_single_string(&cell.display_lines(u16::MAX))),
Some("/usage daily\n\n Token activity\n Token activity unavailable\n".to_string()),
);
chat.finalize_turn();
assert!(!chat.token_activity_history_insertion_blocked());
assert!(
std::iter::from_fn(|| rx.try_recv().ok())
.any(|event| matches!(event, AppEvent::CommitCompletedTokenActivityOutput))
);
assert!(chat.take_completed_token_activity_output().is_some());
}
#[tokio::test]
async fn completed_token_activity_refresh_waits_for_queued_stream_consolidation() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
let request_id = dispatch_usage_and_expect_refresh(&mut chat, &mut rx);
chat.on_agent_message_delta("partial response".to_string());
chat.finalize_completed_assistant_message(/*message*/ None);
assert!(chat.pending_stream_consolidations > 0);
assert!(
chat.finish_token_activity_refresh(
request_id,
Err("token activity unavailable".to_string()),
)
);
assert!(chat.token_activity_history_insertion_blocked());
chat.note_stream_consolidation_completed();
assert!(!chat.token_activity_history_insertion_blocked());
}
#[tokio::test]
async fn completed_token_activity_refresh_waits_for_active_history_cell() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
let request_id = dispatch_usage_and_expect_refresh(&mut chat, &mut rx);
chat.transcript.active_cell = Some(Box::new(PlainHistoryCell::new(vec![Line::from(
"active tool",
)])));
assert!(
chat.finish_token_activity_refresh(
request_id,
Err("token activity unavailable".to_string()),
)
);
assert!(chat.token_activity_history_insertion_blocked());
chat.flush_active_cell();
assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_)));
assert_matches!(
rx.try_recv(),
Ok(AppEvent::CommitCompletedTokenActivityOutput)
);
}
#[tokio::test]
async fn completed_token_activity_refresh_waits_for_active_hook() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
handle_hook_started(
&mut chat,
hook_run(
"post-tool-use:0:/tmp/hooks.json",
codex_app_server_protocol::HookEventName::PostToolUse,
codex_app_server_protocol::HookRunStatus::Running,
"checking output policy",
Vec::new(),
),
);
let request_id = dispatch_usage_and_expect_refresh(&mut chat, &mut rx);
assert!(
chat.finish_token_activity_refresh(
request_id,
Err("token activity unavailable".to_string()),
)
);
assert!(chat.token_activity_history_insertion_blocked());
handle_hook_completed(
&mut chat,
hook_run(
"post-tool-use:0:/tmp/hooks.json",
codex_app_server_protocol::HookEventName::PostToolUse,
codex_app_server_protocol::HookRunStatus::Completed,
"checking output policy",
vec![codex_app_server_protocol::HookOutputEntry {
kind: codex_app_server_protocol::HookOutputEntryKind::Context,
text: "hook context".to_string(),
}],
),
);
assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_)));
assert_matches!(
rx.try_recv(),
Ok(AppEvent::CommitCompletedTokenActivityOutput)
);
}
#[tokio::test]
async fn completed_token_activity_refresh_retries_after_plan_item_completion() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
let request_id = dispatch_usage_and_expect_refresh(&mut chat, &mut rx);
let mut controller = crate::streaming::controller::PlanStreamController::new(
/*width*/ None,
&chat.config.cwd,
chat.history_render_mode(),
);
controller.push("Plan details");
chat.plan_stream_controller = Some(controller);
assert!(
chat.finish_token_activity_refresh(
request_id,
Err("token activity unavailable".to_string()),
)
);
chat.on_plan_item_completed("Plan details".to_string());
assert!(
std::iter::from_fn(|| rx.try_recv().ok())
.any(|event| matches!(event, AppEvent::CommitCompletedTokenActivityOutput))
);
}
#[tokio::test]
async fn pending_token_activity_refresh_keeps_composer_visible_in_short_viewport() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
chat.transcript.active_cell = Some(Box::new(PlainHistoryCell::new(
std::iter::repeat_n(Line::from("active output"), /*n*/ 20).collect(),
)));
dispatch_usage_and_expect_refresh(&mut chat, &mut rx);
let width: u16 = 80;
let height: u16 = 8;
let backend = VT100Backend::new(width, height);
let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal");
term.set_viewport_area(Rect::new(/*x*/ 0, /*y*/ 0, width, height));
term.draw(|f| {
chat.render(f.area(), f.buffer_mut());
})
.unwrap();
assert!(
term.backend()
.vt100()
.screen()
.contents()
.contains("Ask Codex to do anything")
);
}
#[tokio::test]
async fn repeated_token_activity_refreshes_keep_only_latest_card() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
let first_request_id = dispatch_usage_and_expect_refresh(&mut chat, &mut rx);
chat.dispatch_command_with_args(SlashCommand::Usage, "weekly".to_string(), Vec::new());
let second_request_id = expect_token_activity_refresh(&mut rx);
assert_eq!(
chat.pending_token_activity_output()
.map(|cell| lines_to_single_string(&cell.display_lines(u16::MAX))),
Some("/usage weekly\n\n Token activity\n Loading...\n".to_string()),
);
assert!(!chat.finish_token_activity_refresh(
first_request_id,
Err("stale token activity result".to_string()),
));
assert!(chat.finish_token_activity_refresh(
second_request_id,
Err("token activity unavailable".to_string()),
));
}
#[tokio::test]
async fn unrecognized_slash_command_is_not_added_to_local_recall() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -421,6 +421,7 @@ async fn configured_pet_load_is_deferred_until_after_construction() {
initial_user_message: None,
enhanced_keys_supported: false,
has_chatgpt_account: false,
has_codex_backend_auth: false,
model_catalog: test_model_catalog(&cfg),
feedback: codex_feedback::CodexFeedback::new(),
is_first_run: true,
+282
View File
@@ -0,0 +1,282 @@
//! Coordinates asynchronous `/usage` cards in the chat widget.
//!
//! The slash command builds a composite history cell immediately, but the widget
//! keeps that cell transient while the account request runs. The transient card is
//! rendered above the composer through [`ChatWidget::pending_token_activity_output`]
//! so loading never requires clearing or rewriting transcript history. When the
//! matching response arrives, [`TokenActivityHandle`] updates the shared card state
//! and [`ChatWidget::finish_token_activity_refresh`] moves the cell into a completed
//! slot. Event dispatch commits that completed cell into history only after active
//! output and stream consolidation no longer block insertion.
//!
//! Pure chart rendering and date bucketing live in [`chart`]. This module owns
//! request correlation, transient/completed card state, and integration with
//! `ChatWidget` history insertion.
mod chart;
use std::sync::Arc;
use std::sync::RwLock;
use chrono::NaiveDate;
use chrono::Utc;
use codex_app_server_protocol::GetAccountTokenUsageResponse;
use ratatui::style::Stylize;
use ratatui::text::Line;
use super::ChatWidget;
use crate::app_event::AppEvent;
use crate::history_cell::CompositeHistoryCell;
use crate::history_cell::HistoryCell;
use crate::history_cell::PlainHistoryCell;
use crate::history_cell::plain_lines;
pub(super) use chart::TokenActivityView;
/// Tracks the renderable lifecycle of one token activity history cell.
#[derive(Debug)]
enum TokenActivityState {
Loading,
Loaded {
response: GetAccountTokenUsageResponse,
today: NaiveDate,
},
Error,
}
/// Completes an asynchronously rendered token activity history cell.
///
/// Clones share the same card state, allowing the background request path to
/// update a cell still owned by the widget's transient-output state. The widget
/// remains responsible for request-ID matching, redraws, and history insertion.
#[derive(Clone, Debug)]
pub(super) struct TokenActivityHandle {
state: Arc<RwLock<TokenActivityState>>,
}
/// Holds the one transient token activity card waiting on its background response.
///
/// The request ID prevents late results from mutating a newer `/usage` card. The
/// cell stays out of transcript history until the matching response completes and
/// the widget confirms that active output no longer blocks insertion.
pub(super) struct PendingTokenActivityOutput {
request_id: u64,
cell: CompositeHistoryCell,
handle: TokenActivityHandle,
}
impl TokenActivityHandle {
/// Replaces the loading state with either fetched activity or an unavailable state.
///
/// This method intentionally discards the error string because the TUI exposes
/// one stable unavailable message. Calling it more than once replaces the prior
/// terminal state, so request-ID matching should happen before completion.
pub(super) fn finish(&self, result: Result<GetAccountTokenUsageResponse, String>) {
self.finish_with_today(result, Utc::now().date_naive());
}
fn finish_with_today(
&self,
result: Result<GetAccountTokenUsageResponse, String>,
today: NaiveDate,
) {
let state = match result {
Ok(response) => TokenActivityState::Loaded { response, today },
Err(_) => TokenActivityState::Error,
};
#[expect(clippy::expect_used)]
let mut current = self.state.write().expect("token activity state poisoned");
*current = state;
}
}
/// Renders one `/usage` card from shared asynchronous state.
#[derive(Debug)]
struct TokenActivityHistoryCell {
view: TokenActivityView,
state: Arc<RwLock<TokenActivityState>>,
}
/// Creates the card contents and completion handle for one `/usage` invocation.
///
/// The composite cell includes the echoed slash command and a loading card from
/// the start. Callers must retain the returned handle and complete it when the
/// matching background response arrives; otherwise the transient card stays loading.
pub(super) fn new_token_activity_output(
view: TokenActivityView,
) -> (CompositeHistoryCell, TokenActivityHandle) {
let command = PlainHistoryCell::new(vec![
format!("/usage {}", view.label().to_lowercase())
.magenta()
.into(),
]);
let state = Arc::new(RwLock::new(TokenActivityState::Loading));
let handle = TokenActivityHandle {
state: Arc::clone(&state),
};
let card = TokenActivityHistoryCell { view, state };
(
CompositeHistoryCell::new(vec![Box::new(command), Box::new(card)]),
handle,
)
}
impl HistoryCell for TokenActivityHistoryCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
#[expect(clippy::expect_used)]
let state = self.state.read().expect("token activity state poisoned");
match &*state {
TokenActivityState::Loading => {
vec![
" Token activity".bold().into(),
" Loading...".dim().into(),
]
}
TokenActivityState::Error => vec![
" Token activity".bold().into(),
" Token activity unavailable".dim().into(),
],
TokenActivityState::Loaded { response, today } => {
chart::loaded_lines(self.view, response, *today, width)
}
}
}
fn raw_lines(&self) -> Vec<Line<'static>> {
plain_lines(self.display_lines(u16::MAX))
}
}
impl ChatWidget {
/// Starts a token activity refresh and replaces the current transient card.
///
/// Each invocation receives a request ID so background responses update only
/// their own card. The card remains outside transcript history until completion,
/// which keeps loading visible without disturbing existing transcript content.
pub(super) fn add_token_activity_output(&mut self, view: TokenActivityView) {
let request_id = self.next_token_activity_request_id;
self.next_token_activity_request_id =
self.next_token_activity_request_id.wrapping_add(/*rhs*/ 1);
let (cell, handle) = new_token_activity_output(view);
self.completed_token_activity_output = None;
self.refreshing_token_activity_output = Some(PendingTokenActivityOutput {
request_id,
cell,
handle,
});
self.bump_active_cell_revision();
self.request_redraw();
self.app_event_tx
.send(AppEvent::RefreshTokenActivity { request_id });
}
/// Returns the transient token activity card that should render above the composer.
///
/// A loading card takes precedence over a completed card waiting for history
/// insertion. Callers should render the returned cell but leave ownership with
/// the widget so completion and insertion can update it safely.
pub(super) fn pending_token_activity_output(&self) -> Option<&dyn HistoryCell> {
self.refreshing_token_activity_output
.as_ref()
.map(|output| &output.cell as &dyn HistoryCell)
.or_else(|| {
self.completed_token_activity_output
.as_ref()
.map(|cell| cell as &dyn HistoryCell)
})
}
/// Applies a background token activity result to its matching transient card.
///
/// Returns `true` when the pending request matched and moved into the completed
/// slot. Late responses return `false`, including responses for cards replaced
/// by a newer `/usage` invocation or cleared during transcript changes.
pub(crate) fn finish_token_activity_refresh(
&mut self,
request_id: u64,
result: Result<GetAccountTokenUsageResponse, String>,
) -> bool {
let Some(output) = self.refreshing_token_activity_output.take() else {
return false;
};
if output.request_id != request_id {
self.refreshing_token_activity_output = Some(output);
return false;
}
output.handle.finish(result);
self.completed_token_activity_output = Some(output.cell);
self.bump_active_cell_revision();
self.request_redraw();
true
}
/// Reports whether a completed token activity card must wait before insertion.
///
/// Inserting while a stream, queued consolidation, or active transcript cell is
/// present can reorder the card relative to visible output, so callers retry once
/// these barriers clear.
pub(crate) fn token_activity_history_insertion_blocked(&self) -> bool {
self.stream_controller.is_some()
|| self.plan_stream_controller.is_some()
|| self.pending_stream_consolidations > 0
|| self.transcript.active_cell.is_some()
|| self.active_hook_cell.is_some()
}
/// Records a stream consolidation barrier that delays token card insertion.
///
/// Each queued consolidation should eventually call
/// [`ChatWidget::note_stream_consolidation_completed`].
pub(crate) fn note_stream_consolidation_queued(&mut self) {
self.pending_stream_consolidations =
self.pending_stream_consolidations.saturating_add(/*rhs*/ 1);
}
/// Releases one queued stream consolidation barrier.
///
/// The counter saturates at zero so an unmatched completion does not underflow,
/// but paired queue/completion calls are still the intended contract.
pub(crate) fn note_stream_consolidation_completed(&mut self) {
self.pending_stream_consolidations =
self.pending_stream_consolidations.saturating_sub(/*rhs*/ 1);
}
/// Transfers the completed token activity card into the history insertion path.
///
/// Callers should use this only after
/// [`ChatWidget::token_activity_history_insertion_blocked`] returns `false`;
/// taking the card removes it from the transient render area.
pub(crate) fn take_completed_token_activity_output(&mut self) -> Option<CompositeHistoryCell> {
let output = self.completed_token_activity_output.take()?;
self.bump_active_cell_revision();
Some(output)
}
/// Requests another insertion attempt when a completed card is waiting.
///
/// This is used after stream or history lifecycle events that may have cleared
/// the insertion barriers without directly owning the completed card.
pub(crate) fn request_completed_token_activity_output_insertion(&self) {
if self.completed_token_activity_output.is_some() {
self.app_event_tx
.send(AppEvent::CommitCompletedTokenActivityOutput);
}
}
/// Drops transient and completed token cards that must no longer update.
///
/// Late background responses cannot mutate cards after a transcript reset,
/// backtrack, or replacement flow clears this widget-owned state.
pub(crate) fn clear_pending_token_activity_refreshes(&mut self) {
let cleared_refresh = self.refreshing_token_activity_output.take().is_some();
let cleared_completed = self.completed_token_activity_output.take().is_some();
if cleared_refresh || cleared_completed {
self.bump_active_cell_revision();
self.request_redraw();
}
}
}
#[cfg(test)]
#[path = "tokens_tests.rs"]
mod tests;
+474
View File
@@ -0,0 +1,474 @@
//! Renders account token usage summaries and activity charts for `/usage`.
//!
//! This module owns the chart data bucketing and ratatui line construction. The
//! async card lifecycle stays in the parent `tokens` module so chart rendering
//! remains a pure transformation from a loaded usage response.
mod palette;
use std::collections::BTreeMap;
use chrono::Datelike;
use chrono::Duration;
use chrono::NaiveDate;
use codex_app_server_protocol::GetAccountTokenUsageResponse;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use crate::render::highlight::foreground_style_for_scopes;
use crate::status::format_tokens_compact;
use palette::TokenActivityPalette;
const WEEK_COUNT: usize = 52;
const DAY_COUNT: usize = 7;
const CELL_COUNT: usize = WEEK_COUNT * DAY_COUNT;
const CHART_LEFT_WIDTH: usize = 4;
const SUMMARY_INDENT: &str = " ";
const SUMMARY_INDENT_WIDTH: u16 = 1;
/// Selects the aggregation represented by the token activity chart.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(in crate::chatwidget) enum TokenActivityView {
Daily,
Weekly,
Cumulative,
}
impl TokenActivityView {
/// Parses the optional `/usage` argument into a supported chart view.
///
/// An empty argument selects the daily view so `/usage` and `/usage daily`
/// behave identically. Returning `None` lets the slash-command dispatcher
/// report unsupported arguments instead of silently choosing a view.
pub(in crate::chatwidget) fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"" | "day" | "daily" => Some(Self::Daily),
"week" | "weekly" => Some(Self::Weekly),
"cumulative" => Some(Self::Cumulative),
_ => None,
}
}
pub(super) fn label(self) -> &'static str {
match self {
Self::Daily => "Daily",
Self::Weekly => "Weekly",
Self::Cumulative => "Cumulative",
}
}
}
pub(super) fn loaded_lines(
view: TokenActivityView,
response: &GetAccountTokenUsageResponse,
today: NaiveDate,
width: u16,
) -> Vec<Line<'static>> {
let mut lines = vec![
vec![
Span::from(" Token activity").bold(),
Span::styled(" last 12 months", label_style()),
]
.into(),
];
lines.extend(summary_lines(response, graph_width(width)));
// Separate the headline numbers from the calendar below.
lines.push(Line::default());
let Some(buckets) = response.daily_usage_buckets.as_ref() else {
lines.push(" Token activity history unavailable".dim().into());
return lines;
};
lines.extend(chart_lines(view, buckets, today, width));
lines
}
fn chart_lines(
view: TokenActivityView,
buckets: &[codex_app_server_protocol::AccountTokenUsageDailyBucket],
today: NaiveDate,
width: u16,
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
let values = daily_values(buckets, today);
let shown_columns = shown_columns(width);
if shown_columns == 0 {
lines.push(" Widen terminal to show activity graph".dim().into());
return lines;
}
let palette = TokenActivityPalette::current();
let levels = levels_for_view(&values, view);
let first_column = WEEK_COUNT - shown_columns;
lines.push(month_labels(today, first_column, shown_columns));
for row in 0..DAY_COUNT {
let mut spans = vec![weekday_label(view, row)];
for column in first_column..WEEK_COUNT {
if column > first_column {
spans.push(" ".into());
}
let index = column * DAY_COUNT + row;
if view == TokenActivityView::Daily
&& cell_date(today, index).is_some_and(|date| date > today)
{
spans.push(" ".into());
} else {
let style = if view == TokenActivityView::Daily {
palette.for_level(levels[index])
} else {
palette.for_bar_level(levels[index])
};
spans.push(Span::styled(palette.glyph(view, levels[index]), style));
}
}
lines.push(spans.into());
}
// Separate the calendar from the legend/footer below.
lines.push(Line::default());
match view {
TokenActivityView::Daily => lines.push(legend_line(&palette)),
TokenActivityView::Weekly | TokenActivityView::Cumulative => {
lines.push(bar_caption(view, &values))
}
}
lines.push(view_footer(view));
lines
}
fn shown_columns(width: u16) -> usize {
(usize::from(width)
.saturating_sub(CHART_LEFT_WIDTH)
.saturating_add(/*rhs*/ 1)
/ 2)
.min(WEEK_COUNT)
}
fn graph_width(width: u16) -> u16 {
if width == u16::MAX {
return width;
}
(CHART_LEFT_WIDTH + shown_columns(width) * 2 - 1) as u16
}
fn summary_lines(response: &GetAccountTokenUsageResponse, width: u16) -> Vec<Line<'static>> {
let summary = &response.summary;
let fields = [
("Lifetime", format_optional_tokens(summary.lifetime_tokens)),
("Peak", format_optional_tokens(summary.peak_daily_tokens)),
(
"Streak",
format_streak(summary.current_streak_days, summary.longest_streak_days),
),
(
"Longest task",
format_optional_duration(summary.longest_running_turn_sec),
),
];
pack_fields(&fields, width)
.into_iter()
.map(|group| align_summary_line(summary_line(&fields, &group), width))
.collect()
}
/// Greedily pack summary fields into as few lines as fit `width`,
/// keeping field order. `u16::MAX` (raw/copy mode) always yields one line.
fn pack_fields(fields: &[(&str, String)], width: u16) -> Vec<Vec<usize>> {
if width == u16::MAX {
return vec![(0..fields.len()).collect()];
}
let max = usize::from(width.saturating_sub(SUMMARY_INDENT_WIDTH));
let mut groups: Vec<Vec<usize>> = Vec::new();
let mut current: Vec<usize> = Vec::new();
for index in 0..fields.len() {
let mut candidate = current.clone();
candidate.push(index);
if !current.is_empty() && summary_line(fields, &candidate).width() > max {
groups.push(std::mem::take(&mut current));
current.push(index);
} else {
current = candidate;
}
}
if !current.is_empty() {
groups.push(current);
}
groups
}
fn summary_line(fields: &[(&str, String)], indexes: &[usize]) -> Line<'static> {
let mut spans = Vec::new();
for (index, field_index) in indexes.iter().enumerate() {
if index > 0 {
spans.push(Span::styled(" · ", label_style()));
}
let (label, value) = &fields[*field_index];
spans.push(Span::styled(format!("{label} "), label_style()));
spans.push(Span::styled(value.clone(), numeric_style()));
}
spans.into()
}
fn align_summary_line(mut line: Line<'static>, width: u16) -> Line<'static> {
if width == u16::MAX {
return line;
}
line.spans.insert(/*index*/ 0, SUMMARY_INDENT.into());
line
}
fn format_optional_tokens(value: Option<i64>) -> String {
value
.map(format_tokens_compact)
.unwrap_or_else(|| "-".to_string())
}
/// Combine the current and longest streak into one field: a bare `54d` when
/// they match, otherwise `12d (best 54d)`.
fn format_streak(current: Option<i64>, longest: Option<i64>) -> String {
match (current, longest) {
(Some(current), Some(longest)) if current == longest => format!("{current}d"),
(Some(current), Some(longest)) => format!("{current}d (best {longest}d)"),
(Some(current), None) => format!("{current}d"),
(None, Some(longest)) => format!("- (best {longest}d)"),
(None, None) => "-".to_string(),
}
}
fn format_optional_duration(value: Option<i64>) -> String {
value.map_or_else(
|| "-".to_string(),
|seconds| {
let seconds = seconds.max(/*other*/ 0);
let hours = seconds / 3600;
let minutes = (seconds % 3600) / 60;
match (hours, minutes) {
(0, 0) => format!("{seconds}s"),
(0, minutes) => format!("{minutes}m"),
(hours, 0) => format!("{hours}h"),
(hours, minutes) => format!("{hours}h {minutes}m"),
}
},
)
}
fn numeric_style() -> Style {
foreground_style_for_scopes(&["constant.numeric", "constant"])
.unwrap_or_else(|| Style::default().green())
}
fn label_style() -> Style {
foreground_style_for_scopes(&["comment"]).unwrap_or_else(|| Style::default().dim())
}
fn weekday_label(view: TokenActivityView, row: usize) -> Span<'static> {
if view != TokenActivityView::Daily {
// Bar views fill from the bottom (row 6) upward, so the gutter doubles
// as a coarse Y-axis: peak at the top, baseline at the bottom.
return Span::styled(
match row {
0 => "max ",
6 => " 0 ",
_ => " ",
},
label_style(),
);
}
Span::styled(
match row {
0 => " Su ",
1 => " Mo ",
2 => " Tu ",
3 => " We ",
4 => " Th ",
5 => " Fr ",
6 => " Sa ",
_ => " ",
},
label_style(),
)
}
fn legend_line(palette: &TokenActivityPalette) -> Line<'static> {
let mut spans = vec![Span::styled(" Less ", label_style())];
for level in 0..=4 {
if level > 0 {
spans.push(" ".into());
}
spans.push(Span::styled(
palette.glyph(TokenActivityView::Daily, level),
palette.for_level(level),
));
}
spans.push(Span::styled(" More", label_style()));
spans.into()
}
/// Caption for the bar-chart views, where the 5-step daily legend would be
/// misleading. States what each bar represents and the peak it is scaled to.
fn bar_caption(view: TokenActivityView, values: &[i64]) -> Line<'static> {
let weeks = weekly_totals(values);
let (lead, peak) = match view {
TokenActivityView::Weekly => (
"Each column = 1 week · tallest ",
weeks.iter().copied().max().unwrap_or(/*default*/ 0),
),
TokenActivityView::Cumulative => ("Running total · top ", weeks.iter().sum::<i64>()),
TokenActivityView::Daily => ("", 0),
};
if peak <= 0 {
return Span::styled(" No token activity in the last 12 months", label_style()).into();
}
vec![
Span::styled(format!(" {lead}"), label_style()),
Span::styled(format_tokens_compact(peak), numeric_style()),
]
.into()
}
/// Dim footer that surfaces the other `/usage` views and emphasizes the
/// active one, so the weekly/cumulative modes are discoverable from the card.
fn view_footer(active: TokenActivityView) -> Line<'static> {
let mut spans = vec![Span::styled(" ", label_style())];
let views = [
(TokenActivityView::Daily, "daily"),
(TokenActivityView::Weekly, "weekly"),
(TokenActivityView::Cumulative, "cumulative"),
];
for (index, (view, name)) in views.into_iter().enumerate() {
if index > 0 {
spans.push(Span::styled(" · ", label_style()));
}
let style = if view == active {
numeric_style().bold()
} else {
label_style()
};
spans.push(Span::styled(name, style));
}
spans.into()
}
fn month_labels(today: NaiveDate, first_column: usize, shown_columns: usize) -> Line<'static> {
let mut cells = vec![' '; shown_columns * 2 - 1];
let start = chart_start(today);
let mut last_end = 0;
for column in first_column..WEEK_COUNT {
let date = start + Duration::days((column * DAY_COUNT) as i64);
if date.day() > 7 {
continue;
}
let label = date.format("%b").to_string();
let offset = (column - first_column) * 2;
if offset < last_end || offset + label.len() > cells.len() {
continue;
}
for (index, ch) in label.chars().enumerate() {
cells[offset + index] = ch;
}
last_end = offset + label.len() + 1;
}
vec![
" ".into(),
Span::styled(cells.into_iter().collect::<String>(), label_style()),
]
.into()
}
/// Normalizes backend daily buckets into the fixed 52-week display window.
///
/// The returned vector is ordered by chart cell, starting with the oldest Sunday.
/// Invalid, out-of-window, and future dates are ignored. Duplicate dates are
/// accumulated and negative token values do not reduce activity.
fn daily_values(
buckets: &[codex_app_server_protocol::AccountTokenUsageDailyBucket],
today: NaiveDate,
) -> Vec<i64> {
let start = chart_start(today);
let end = start + Duration::days(CELL_COUNT as i64);
let mut by_date = BTreeMap::new();
for bucket in buckets {
let Ok(date) = NaiveDate::parse_from_str(&bucket.start_date, "%Y-%m-%d") else {
continue;
};
if date < start || date >= end || date > today {
continue;
}
*by_date.entry(date).or_insert(/*default*/ 0) += bucket.tokens.max(/*other*/ 0);
}
(0..CELL_COUNT)
.map(|offset| {
by_date
.get(&(start + Duration::days(offset as i64)))
.copied()
.unwrap_or(/*default*/ 0)
})
.collect()
}
fn levels_for_view(values: &[i64], view: TokenActivityView) -> Vec<usize> {
match view {
TokenActivityView::Daily => graded_levels(values),
TokenActivityView::Weekly => bar_levels(&weekly_totals(values)),
TokenActivityView::Cumulative => {
let cumulative = weekly_totals(values)
.into_iter()
.scan(/*initial_state*/ 0, |sum, value| {
*sum += value;
Some(*sum)
})
.collect::<Vec<_>>();
bar_levels(&cumulative)
}
}
}
fn graded_levels(values: &[i64]) -> Vec<usize> {
let max = values.iter().copied().max().unwrap_or(/*default*/ 0);
values
.iter()
.map(|value| match (*value, max) {
(0, _) | (_, 0) => 0,
(value, max) if value * 4 > max * 3 => 4,
(value, max) if value * 2 > max => 3,
(value, max) if value * 4 > max => 2,
_ => 1,
})
.collect()
}
fn weekly_totals(values: &[i64]) -> Vec<i64> {
values
.chunks(DAY_COUNT)
.map(|week| week.iter().sum())
.collect()
}
fn bar_levels(totals: &[i64]) -> Vec<usize> {
let max = totals.iter().copied().max().unwrap_or(/*default*/ 0);
totals
.iter()
.flat_map(|value| {
let height = if *value <= 0 || max <= 0 {
0
} else {
((*value * DAY_COUNT as i64 + max - 1) / max) as usize
};
(0..DAY_COUNT).map(move |row| if DAY_COUNT - row <= height { 4 } else { 0 })
})
.collect()
}
fn chart_start(today: NaiveDate) -> NaiveDate {
let week_start = today - Duration::days(i64::from(today.weekday().num_days_from_sunday()));
week_start - Duration::weeks((WEEK_COUNT - 1) as i64)
}
fn cell_date(today: NaiveDate, index: usize) -> Option<NaiveDate> {
chart_start(today).checked_add_signed(Duration::days(index as i64))
}
#[cfg(test)]
#[path = "chart_tests.rs"]
mod tests;
@@ -0,0 +1,152 @@
//! Builds terminal-aware styles and glyph choices for token activity charts.
//!
//! The palette adapts theme colors to the active terminal color level while
//! keeping chart-specific glyph policy local to the token activity renderer.
use ratatui::style::Color;
use ratatui::style::Style;
use ratatui::style::Stylize;
use super::TokenActivityView;
use crate::color::blend;
use crate::render::highlight::foreground_style_for_scopes;
use crate::style::accent_style;
use crate::terminal_palette::StdoutColorLevel;
use crate::terminal_palette::best_color_for_level;
use crate::terminal_palette::default_bg;
use crate::terminal_palette::default_fg;
use crate::terminal_palette::stdout_color_level;
// In low-color terminals we distinguish empty vs active cells by glyph (a
// width-matched filled/hollow pair). In truecolor terminals the grid uses a
// single glyph and lets color carry the intensity (GitHub-style), which keeps
// the grid perfectly aligned and free of texture noise.
const EMPTY_CELL_GLYPH: &str = "";
const ACTIVE_CELL_GLYPH: &str = "";
const BAR_CELL_GLYPH: &str = "";
/// Stores the terminal-specific styles and glyph strategy for token activity cells.
pub(super) struct TokenActivityPalette {
styles: [Style; 5],
bar_style: Style,
/// True when the terminal supports a truecolor gradient, so the grid can
/// encode intensity purely by color and render every cell with a single
/// glyph. False on low-color terminals, where we fall back to a
/// filled/hollow glyph pair so empty and active cells stay distinguishable.
uses_color: bool,
}
impl TokenActivityPalette {
pub(super) fn current() -> Self {
Self::from_parts(
default_fg(),
default_bg(),
stdout_color_level(),
theme_activity_style(),
)
}
fn from_parts(
default_fg: Option<(u8, u8, u8)>,
default_bg: Option<(u8, u8, u8)>,
color_level: StdoutColorLevel,
active_style: Style,
) -> Self {
let fallback_palette = || Self::fallback(active_style);
let (Some(fg), Some(bg), Some(anchor)) =
(default_fg, default_bg, activity_anchor_rgb(active_style))
else {
return fallback_palette();
};
if matches!(
color_level,
StdoutColorLevel::Ansi16 | StdoutColorLevel::Unknown
) {
return fallback_palette();
}
let empty_alpha = if crate::color::is_light(bg) {
0.18
} else {
0.14
};
let alphas = [empty_alpha, 0.22, 0.42, 0.68, 1.00];
let styles = std::array::from_fn(|index| {
let color = if index == 0 {
blend(fg, bg, alphas[index])
} else {
blend(anchor, bg, alphas[index])
};
Style::default().fg(best_color_for_level(color, color_level))
});
let bar_style = Style::default().fg(best_color_for_level(
blend(anchor, bg, /*alpha*/ 0.78),
color_level,
));
Self {
styles,
bar_style,
uses_color: true,
}
}
fn fallback(active_style: Style) -> Self {
let empty_style = Style::default().dim();
Self {
styles: [
empty_style,
active_style,
active_style,
active_style,
active_style,
],
bar_style: active_style,
uses_color: false,
}
}
pub(super) fn for_level(&self, level: usize) -> Style {
self.styles[level.min(/*other*/ 4)]
}
pub(super) fn for_bar_level(&self, level: usize) -> Style {
if level == 0 {
self.for_level(/*level*/ 0)
} else {
self.bar_style
}
}
/// The glyph for a cell at `level`. Daily truecolor renders every visible
/// cell with the same square glyph and lets color carry the intensity; in
/// low-color we use the hollow glyph for empty cells so they remain visible
/// without a color gradient. Bar views use full blocks for filled height and
/// spaces for empty height so the silhouette reads as a column chart.
pub(super) fn glyph(&self, view: TokenActivityView, level: usize) -> &'static str {
if view != TokenActivityView::Daily {
return if level == 0 { " " } else { BAR_CELL_GLYPH };
}
if self.uses_color || level > 0 {
ACTIVE_CELL_GLYPH
} else {
EMPTY_CELL_GLYPH
}
}
}
fn theme_activity_style() -> Style {
foreground_style_for_scopes(&["entity.name.type", "support.type", "variable"])
.unwrap_or_else(accent_style)
.bold()
}
fn activity_anchor_rgb(style: Style) -> Option<(u8, u8, u8)> {
match style.fg? {
Color::Rgb(r, g, b) => Some((r, g, b)),
_ => None,
}
}
#[cfg(test)]
#[path = "palette_tests.rs"]
mod tests;
@@ -0,0 +1,115 @@
use super::*;
use crate::terminal_palette::rgb_color;
use pretty_assertions::assert_eq;
use ratatui::style::Modifier;
#[test]
fn truecolor_palette_blends_theme_accent_against_dark_background() {
let default_fg = Some((240, 240, 240));
let default_bg = Some((0, 0, 0));
let active_style = Style::default().fg(rgb_color((100, 200, 50))).bold();
let palette = TokenActivityPalette::from_parts(
default_fg,
default_bg,
StdoutColorLevel::TrueColor,
active_style,
);
assert_eq!(
palette.for_level(/*level*/ 0).fg,
Some(rgb_color((33, 33, 33)))
);
assert_eq!(
palette.for_level(/*level*/ 1).fg,
Some(rgb_color((22, 44, 11)))
);
assert_eq!(
palette.for_level(/*level*/ 4).fg,
Some(rgb_color((100, 200, 50)))
);
assert_eq!(
palette.for_bar_level(/*level*/ 4).fg,
Some(rgb_color((78, 156, 39)))
);
assert!(palette.uses_color);
}
#[test]
fn truecolor_palette_blends_empty_cell_for_light_background() {
let default_fg = Some((0, 0, 0));
let default_bg = Some((255, 255, 255));
let active_style = Style::default().fg(rgb_color((0, 95, 135))).bold();
let palette = TokenActivityPalette::from_parts(
default_fg,
default_bg,
StdoutColorLevel::TrueColor,
active_style,
);
assert_eq!(
palette.for_level(/*level*/ 0).fg,
Some(rgb_color((209, 209, 209)))
);
assert_eq!(
palette.for_level(/*level*/ 4).fg,
Some(rgb_color((0, 95, 135)))
);
assert!(palette.uses_color);
}
#[test]
fn ansi16_palette_uses_theme_accent_without_green_fallback() {
let default_fg = Some((240, 240, 240));
let default_bg = Some((0, 0, 0));
let active_style = Style::default().fg(Color::Magenta).bold();
let palette = TokenActivityPalette::from_parts(
default_fg,
default_bg,
StdoutColorLevel::Ansi16,
active_style,
);
assert_eq!(palette.for_level(/*level*/ 0), Style::default().dim());
assert_eq!(palette.for_level(/*level*/ 1), active_style);
assert_eq!(palette.for_bar_level(/*level*/ 4), active_style);
assert!(!palette.uses_color);
}
#[test]
fn non_rgb_theme_accent_remains_active_fallback() {
let default_fg = Some((240, 240, 240));
let default_bg = Some((0, 0, 0));
let active_style = Style::default().fg(Color::Cyan).bold();
let palette = TokenActivityPalette::from_parts(
default_fg,
default_bg,
StdoutColorLevel::TrueColor,
active_style,
);
assert_eq!(palette.for_level(/*level*/ 1), active_style);
assert!(
palette
.for_level(/*level*/ 1)
.add_modifier
.contains(Modifier::BOLD)
);
assert!(!palette.uses_color);
}
#[test]
fn missing_terminal_colors_use_theme_accent_fallback() {
let default_fg = None;
let default_bg = Some((0, 0, 0));
let active_style = Style::default().fg(Color::Blue).bold();
let palette = TokenActivityPalette::from_parts(
default_fg,
default_bg,
StdoutColorLevel::TrueColor,
active_style,
);
assert_eq!(palette.for_level(/*level*/ 0), Style::default().dim());
assert_eq!(palette.for_level(/*level*/ 4), active_style);
assert!(!palette.uses_color);
}
@@ -0,0 +1,243 @@
use super::*;
use codex_app_server_protocol::AccountTokenUsageDailyBucket;
use codex_app_server_protocol::AccountTokenUsageSummary;
use insta::assert_snapshot;
use pretty_assertions::assert_eq;
#[test]
fn duplicate_dates_sum_and_negative_values_clamp() {
let today =
NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 5, /*day*/ 29).expect("valid date");
let buckets = vec![
AccountTokenUsageDailyBucket {
start_date: "2026-05-29".to_string(),
tokens: 10,
},
AccountTokenUsageDailyBucket {
start_date: "2026-05-29".to_string(),
tokens: 5,
},
AccountTokenUsageDailyBucket {
start_date: "2026-05-28".to_string(),
tokens: -4,
},
];
let values = daily_values(&buckets, today);
assert_eq!(values.iter().sum::<i64>(), 15);
}
#[test]
fn bar_levels_fill_from_bottom() {
let levels = bar_levels(&[0, 10]);
assert_eq!(&levels[..DAY_COUNT], &[0; DAY_COUNT]);
assert_eq!(&levels[DAY_COUNT..], &[4; DAY_COUNT]);
}
#[test]
fn token_activity_view_aliases_parse() {
assert_eq!(TokenActivityView::parse(""), Some(TokenActivityView::Daily));
assert_eq!(
TokenActivityView::parse("day"),
Some(TokenActivityView::Daily)
);
assert_eq!(
TokenActivityView::parse("week"),
Some(TokenActivityView::Weekly)
);
assert_eq!(
TokenActivityView::parse("cumulative"),
Some(TokenActivityView::Cumulative)
);
assert_eq!(TokenActivityView::parse("year"), None);
}
#[test]
fn daily_graph_snapshot_uses_distinct_empty_and_active_cells() {
let today =
NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 5, /*day*/ 29).expect("valid date");
let buckets = vec![
AccountTokenUsageDailyBucket {
start_date: "2026-05-25".to_string(),
tokens: 1,
},
AccountTokenUsageDailyBucket {
start_date: "2026-05-29".to_string(),
tokens: 4,
},
];
let rendered = chart_lines(TokenActivityView::Daily, &buckets, today, /*width*/ 22)
.into_iter()
.map(|line| line.to_string().trim_end().to_string())
.collect::<Vec<_>>()
.join("\n");
assert_snapshot!(rendered, @r"
Apr May
Su
Mo
Tu
We
Th
Fr
Sa
Less More
daily · weekly · cumulative
");
}
#[test]
fn daily_graph_snapshot_stays_left_aligned_in_wide_terminal() {
assert_eq!(graph_width(/*width*/ 160), 107);
assert_eq!(graph_width(/*width*/ u16::MAX), u16::MAX);
let today =
NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 5, /*day*/ 29).expect("valid date");
let lines = chart_lines(TokenActivityView::Daily, &[], today, /*width*/ 160);
let rendered = [&lines[0], &lines[1], lines.last().expect("legend line")]
.into_iter()
.map(|line| line.to_string().trim_end().to_string())
.collect::<Vec<_>>()
.join("\n");
assert_snapshot!(rendered, @"
Jun Jul Aug Sep Oct Nov Dec Jan Feb Mar Apr May
Su
daily · weekly · cumulative
");
}
#[test]
fn weekly_graph_snapshot_renders_bar_chart_and_caption() {
let today =
NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 5, /*day*/ 29).expect("valid date");
let buckets = vec![
AccountTokenUsageDailyBucket {
start_date: "2026-05-11".to_string(),
tokens: 3,
},
AccountTokenUsageDailyBucket {
start_date: "2026-05-18".to_string(),
tokens: 6,
},
AccountTokenUsageDailyBucket {
start_date: "2026-05-25".to_string(),
tokens: 9,
},
];
let rendered = chart_lines(
TokenActivityView::Weekly,
&buckets,
today,
/*width*/ 22,
)
.into_iter()
.map(|line| line.to_string().trim_end().to_string())
.collect::<Vec<_>>()
.join("\n");
assert_snapshot!(rendered, @"
Apr May
max
0
Each column = 1 week · tallest 9
daily · weekly · cumulative
");
}
#[test]
fn cumulative_graph_snapshot_renders_running_total_bar_chart_and_caption() {
let today =
NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 5, /*day*/ 29).expect("valid date");
let buckets = vec![
AccountTokenUsageDailyBucket {
start_date: "2026-05-11".to_string(),
tokens: 3,
},
AccountTokenUsageDailyBucket {
start_date: "2026-05-18".to_string(),
tokens: 6,
},
AccountTokenUsageDailyBucket {
start_date: "2026-05-25".to_string(),
tokens: 9,
},
];
let rendered = chart_lines(
TokenActivityView::Cumulative,
&buckets,
today,
/*width*/ 22,
)
.into_iter()
.map(|line| line.to_string().trim_end().to_string())
.collect::<Vec<_>>()
.join("\n");
assert_snapshot!(rendered, @"
Apr May
max
0
Running total · top 18
daily · weekly · cumulative
");
}
#[test]
fn summary_snapshot_left_aligns_and_splits_when_needed() {
let response = GetAccountTokenUsageResponse {
summary: AccountTokenUsageSummary {
lifetime_tokens: Some(21_400_000_000),
peak_daily_tokens: Some(835_000_000),
longest_running_turn_sec: Some(13_920),
current_streak_days: Some(54),
longest_streak_days: Some(54),
},
daily_usage_buckets: None,
};
let rendered = |width| {
summary_lines(&response, graph_width(width))
.into_iter()
.map(|line| line.to_string().trim_end().to_string())
.collect::<Vec<_>>()
.join("\n")
};
assert_snapshot!(
format!(
"wide:\n{}\n\nnarrow:\n{}\n\ntight:\n{}",
rendered(/*width*/ 120),
rendered(/*width*/ 80),
rendered(/*width*/ 62)
),
@"
wide:
Lifetime 21.4B · Peak 835M · Streak 54d · Longest task 3h 52m
narrow:
Lifetime 21.4B · Peak 835M · Streak 54d · Longest task 3h 52m
tight:
Lifetime 21.4B · Peak 835M · Streak 54d
Longest task 3h 52m
"
);
}
@@ -0,0 +1,38 @@
use super::*;
use codex_app_server_protocol::AccountTokenUsageSummary;
use pretty_assertions::assert_eq;
#[test]
fn loaded_state_freezes_chart_anchor_date_at_completion() {
let state = Arc::new(RwLock::new(TokenActivityState::Loading));
let handle = TokenActivityHandle {
state: Arc::clone(&state),
};
let today =
NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 5, /*day*/ 29).expect("valid date");
handle.finish_with_today(
Ok(GetAccountTokenUsageResponse {
summary: AccountTokenUsageSummary {
lifetime_tokens: None,
peak_daily_tokens: None,
longest_running_turn_sec: None,
current_streak_days: None,
longest_streak_days: None,
},
daily_usage_buckets: None,
}),
today,
);
let state = state.read().expect("token activity state poisoned");
match &*state {
TokenActivityState::Loaded {
today: loaded_today,
..
} => {
assert_eq!(*loaded_today, today);
}
other => panic!("expected loaded state, got {other:?}"),
}
}
+6 -1
View File
@@ -51,7 +51,9 @@ impl ChatWidget {
self.turn_lifecycle.start(Instant::now());
self.transcript.reset_turn_flags();
self.adaptive_chunking.reset();
self.plan_stream_controller = None;
if self.plan_stream_controller.take().is_some() {
self.request_completed_token_activity_output_insertion();
}
self.turn_runtime_metrics = RuntimeMetricsSummary::default();
self.session_telemetry.reset_runtime_metrics();
self.bottom_pane.clear_quit_shortcut_hint();
@@ -122,9 +124,11 @@ impl ChatWidget {
self.add_boxed_history(cell);
}
if let Some(source) = source {
self.note_stream_consolidation_queued();
self.app_event_tx
.send(AppEvent::ConsolidateProposedPlan(source));
}
self.request_completed_token_activity_output_insertion();
}
self.flush_unified_exec_wait_streak();
if !from_replay {
@@ -313,6 +317,7 @@ impl ChatWidget {
self.adaptive_chunking.reset();
self.stream_controller = None;
self.plan_stream_controller = None;
self.request_completed_token_activity_output_insertion();
self.status_state.pending_status_indicator_restore = false;
self.clear_cancel_edit();
self.request_status_line_branch_refresh();
+39 -20
View File
@@ -276,15 +276,13 @@ impl<'a> FlexRenderable<'a> {
let mut allocated_rects = Vec::with_capacity(self.children.len());
let mut child_sizes = vec![0; self.children.len()];
let mut allocated_size = 0;
let mut total_flex = 0;
let mut flex_children = Vec::new();
// 1. Allocate space to non-flex children.
let max_size = area.height;
let mut last_flex_child_idx = 0;
for (i, FlexChild { flex, child }) in self.children.iter().enumerate() {
if *flex > 0 {
total_flex += flex;
last_flex_child_idx = i;
flex_children.push((i, *flex as u16, child.desired_height(area.width)));
} else {
child_sizes[i] = child
.desired_height(area.width)
@@ -293,25 +291,42 @@ impl<'a> FlexRenderable<'a> {
}
}
let free_space = max_size.saturating_sub(allocated_size);
// 2. Allocate space to flex children, proportional to their flex factor.
let mut allocated_flex_space = 0;
if total_flex > 0 {
let space_per_flex = free_space / total_flex as u16;
for (i, FlexChild { flex, child }) in self.children.iter().enumerate() {
if *flex > 0 {
// Last flex child gets all the remaining space, to prevent a rounding error
// from not allocating all the space.
let max_child_extent = if i == last_flex_child_idx {
free_space - allocated_flex_space
} else {
space_per_flex * *flex as u16
};
let child_size = child.desired_height(area.width).min(max_child_extent);
child_sizes[i] = child_size;
allocated_flex_space += child_size;
// 2. Satisfy flex children that need less than their proportional share so their unused
// space can be redistributed instead of leaving blank rows.
let mut remaining_space = free_space;
while !flex_children.is_empty() {
let total_flex = flex_children.iter().map(|(_, flex, _)| *flex).sum::<u16>();
let mut satisfied_any = false;
flex_children.retain(|(i, flex, desired_height)| {
let proportional_share =
(u32::from(remaining_space) * u32::from(*flex) / u32::from(total_flex)) as u16;
if *desired_height <= proportional_share {
child_sizes[*i] = *desired_height;
remaining_space = remaining_space.saturating_sub(*desired_height);
satisfied_any = true;
false
} else {
true
}
});
if !satisfied_any {
break;
}
}
// 3. Divide the remaining space proportionally. The final child absorbs rounding slack.
let total_flex = flex_children.iter().map(|(_, flex, _)| *flex).sum::<u16>();
let mut allocated_flex_space = 0;
let last_flex_child_idx = flex_children.last().map(|(i, _, _)| *i);
for (i, flex, desired_height) in flex_children {
let max_child_extent = if Some(i) == last_flex_child_idx {
remaining_space.saturating_sub(allocated_flex_space)
} else {
(u32::from(remaining_space) * u32::from(flex) / u32::from(total_flex)) as u16
};
let child_size = desired_height.min(max_child_extent);
child_sizes[i] = child_size;
allocated_flex_space += child_size;
}
let mut y = area.y;
for size in child_sizes {
@@ -482,3 +497,7 @@ where
RenderableItem::Owned(Box::new(InsetRenderable { child, insets }))
}
}
#[cfg(test)]
#[path = "renderable_tests.rs"]
mod tests;
@@ -0,0 +1,72 @@
use super::*;
use pretty_assertions::assert_eq;
struct HeightRenderable(u16);
impl HeightRenderable {
fn with_height(height: u16) -> Self {
Self(height)
}
}
impl Renderable for HeightRenderable {
fn render(&self, _area: Rect, _buf: &mut Buffer) {}
fn desired_height(&self, _width: u16) -> u16 {
self.0
}
}
#[test]
fn flex_redistributes_space_unused_by_short_children() {
let mut flex = FlexRenderable::new();
flex.push(
/*flex*/ 1,
RenderableItem::Owned(Box::new(HeightRenderable::with_height(/*height*/ 20))),
);
flex.push(
/*flex*/ 1,
RenderableItem::Owned(Box::new(HeightRenderable::with_height(/*height*/ 2))),
);
let allocated = flex.allocate(Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 10,
));
assert_eq!(
allocated
.into_iter()
.map(|area| area.height)
.collect::<Vec<_>>(),
vec![8, 2],
);
}
#[test]
fn flex_reserves_non_flex_space_before_flexible_children() {
let mut flex = FlexRenderable::new();
flex.push(
/*flex*/ 1,
RenderableItem::Owned(Box::new(HeightRenderable::with_height(/*height*/ 20))),
);
flex.push(
/*flex*/ 0,
RenderableItem::Owned(Box::new(HeightRenderable::with_height(/*height*/ 2))),
);
flex.push(
/*flex*/ 1,
RenderableItem::Owned(Box::new(HeightRenderable::with_height(/*height*/ 20))),
);
let allocated = flex.allocate(Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 10,
));
assert_eq!(
allocated
.into_iter()
.map(|area| area.height)
.collect::<Vec<_>>(),
vec![4, 2, 4],
);
}
+5
View File
@@ -48,6 +48,7 @@ pub enum SlashCommand {
Diff,
Mention,
Status,
Usage,
DebugConfig,
Title,
Statusline,
@@ -102,6 +103,7 @@ impl SlashCommand {
SlashCommand::Import => "import setup, this project, and recent chats from Claude Code",
SlashCommand::Hooks => "view and manage lifecycle hooks",
SlashCommand::Status => "show current session configuration and token usage",
SlashCommand::Usage => "show account usage activity",
SlashCommand::DebugConfig => "show config layers and requirement sources for debugging",
SlashCommand::Title => "configure which items appear in the terminal title",
SlashCommand::Statusline => "configure which items appear in the status line",
@@ -159,6 +161,7 @@ impl SlashCommand {
| SlashCommand::Keymap
| SlashCommand::Mcp
| SlashCommand::Raw
| SlashCommand::Usage
| SlashCommand::Pets
| SlashCommand::Side
| SlashCommand::Btw
@@ -176,6 +179,7 @@ impl SlashCommand {
| SlashCommand::Diff
| SlashCommand::Mention
| SlashCommand::Status
| SlashCommand::Usage
| SlashCommand::Ide
)
}
@@ -214,6 +218,7 @@ impl SlashCommand {
| SlashCommand::Skills
| SlashCommand::Hooks
| SlashCommand::Status
| SlashCommand::Usage
| SlashCommand::DebugConfig
| SlashCommand::Ps
| SlashCommand::Stop
+1 -1
View File
@@ -1,4 +1,4 @@
#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum StatusAccountDisplay {
ChatGpt {
email: Option<String>,
+5
View File
@@ -35,6 +35,11 @@ pub fn best_color(target: (u8, u8, u8)) -> Color {
best_color_for_color_level(target, effective_stdout_color_level())
}
/// Returns the closest color to the target color for a known terminal color level.
pub fn best_color_for_level(target: (u8, u8, u8), color_level: StdoutColorLevel) -> Color {
best_color_for_color_level(target, color_level)
}
fn effective_stdout_color_level() -> StdoutColorLevel {
stdout_color_level_for_terminal(
stdout_color_level(),
+23
View File
@@ -29,6 +29,7 @@ pub(crate) struct TranscriptReflowState {
last_reflow_width: Option<u16>,
pending_reflow_width: Option<u16>,
pending_until: Option<Instant>,
history_cell_refresh_requested: bool,
ran_during_stream: bool,
resize_requested_during_stream: bool,
}
@@ -93,6 +94,12 @@ impl TranscriptReflowState {
self.pending_until = Some(Instant::now());
}
/// Schedule an immediate rebuild because an existing history cell changed its rendered output.
pub(crate) fn schedule_history_cell_refresh(&mut self) {
self.history_cell_refresh_requested = true;
self.schedule_immediate();
}
#[cfg(test)]
pub(crate) fn set_due_for_test(&mut self) {
self.pending_until = Some(Instant::now() - Duration::from_millis(1));
@@ -110,9 +117,14 @@ impl TranscriptReflowState {
self.pending_until.is_some()
}
pub(crate) fn history_cell_refresh_requested(&self) -> bool {
self.history_cell_refresh_requested
}
pub(crate) fn clear_pending_reflow(&mut self) {
self.pending_until = None;
self.pending_reflow_width = None;
self.history_cell_refresh_requested = false;
}
/// Remember the terminal width that actually rebuilt transcript scrollback.
@@ -262,6 +274,17 @@ mod tests {
assert!(state.reflow_needed_for_width(/*width*/ 100));
}
#[test]
fn clear_pending_reflow_clears_history_cell_refresh_request() {
let mut state = TranscriptReflowState::default();
state.schedule_history_cell_refresh();
assert!(state.history_cell_refresh_requested());
state.clear_pending_reflow();
assert!(!state.history_cell_refresh_requested());
}
#[test]
fn mark_reflowed_width_reports_unchanged_width() {
let mut state = TranscriptReflowState::default();