feat(tui): add token activity command

This commit is contained in:
Felipe Coury
2026-05-30 19:31:25 -03:00
parent 4ebef9107d
commit c2e0889cf7
21 changed files with 1154 additions and 8 deletions
+4 -2
View File
@@ -1231,7 +1231,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()?;
@@ -1310,7 +1312,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();
@@ -23,6 +23,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,
@@ -77,6 +80,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/tokenUsage/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,
@@ -646,6 +668,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/tokenUsage/read failed in TUI")
}
pub(super) async fn send_add_credits_nudge_email(
request_handle: AppServerRequestHandle,
credit_type: AddCreditsNudgeCreditType,
+19 -1
View File
@@ -238,7 +238,9 @@ impl App {
}
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();
}
return Ok(AppRunControl::Continue);
}
let end = self.transcript_cells.len();
@@ -683,6 +685,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;
}
@@ -739,6 +744,17 @@ impl App {
}
}
},
AppEvent::TokenActivityLoaded { request_id, result } => {
if let Err(err) = &result {
tracing::warn!("account/tokenUsage/read failed during TUI refresh: {err}");
}
if self
.chat_widget
.finish_token_activity_refresh(request_id, result)
{
self.refresh_updated_history_cell(tui)?;
}
}
AppEvent::ConnectorsLoaded { result, is_final } => {
self.chat_widget.on_connectors_loaded(result, is_final);
}
@@ -1991,6 +2007,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();
@@ -2003,6 +2020,7 @@ impl App {
}
AppEvent::SyntaxThemePreviewed => {
self.refresh_status_line();
tui.frame_requester().schedule_frame();
}
AppEvent::OpenKeymapActionMenu { context, action } => {
self.chat_widget
+1
View File
@@ -94,6 +94,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;
+43 -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,31 @@ impl App {
tui.frame_requester().schedule_frame();
}
/// Rebuild history after an existing cell's render output changes.
///
/// Use the resize-reflow lifecycle so overlays defer the replay and active streams receive the
/// same post-consolidation repair as resize-triggered rebuilds.
pub(super) fn refresh_updated_history_cell(&mut self, tui: &mut tui::Tui) -> Result<()> {
self.schedule_immediate_history_cell_refresh(tui);
self.maybe_run_resize_reflow(tui)
}
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 +326,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 +369,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 +426,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(());
}
+26
View File
@@ -4093,6 +4093,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(),
+1
View File
@@ -252,6 +252,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();
+12
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;
@@ -292,6 +293,17 @@ pub(crate) enum AppEvent {
result: Result<Vec<RateLimitSnapshot>, String>,
},
/// Fetch account-wide token activity for a `/tokens` history card.
RefreshTokenActivity {
request_id: u64,
},
/// Result of fetching account-wide token activity.
TokenActivityLoaded {
request_id: u64,
result: Result<GetAccountTokenUsageResponse, String>,
},
/// Send a user-confirmed request to notify the workspace owner.
SendAddCreditsNudgeEmail {
credit_type: AddCreditsNudgeCreditType,
@@ -368,6 +368,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,
@@ -438,6 +439,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,
@@ -536,6 +538,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,
@@ -587,6 +590,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,
@@ -59,6 +60,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,
@@ -457,6 +459,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,
@@ -485,6 +488,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,
@@ -518,6 +522,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,
@@ -546,6 +551,7 @@ mod tests {
collaboration_modes_enabled: false,
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,
@@ -75,6 +76,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::Tokens)
.filter(|(_, cmd)| flags.goal_command_enabled || *cmd != SlashCommand::Goal)
.filter(|(_, cmd)| flags.personality_command_enabled || *cmd != SlashCommand::Personality)
.filter(|(_, cmd)| flags.realtime_conversation_enabled || *cmd != SlashCommand::Realtime)
@@ -108,11 +110,13 @@ pub(crate) fn commands_for_input(
/// Find a single built-in command by exact name, after applying feature gating.
///
/// Side-conversation gating is intentionally enforced by dispatch rather than exact 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
/// exact 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()?;
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,
@@ -256,6 +261,28 @@ mod tests {
assert_eq!(find_builtin_command("goal", flags), None);
}
#[test]
fn tokens_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::Tokens),
None
);
}
#[test]
fn tokens_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("tokens", flags),
Some(SlashCommand::Tokens)
);
}
#[test]
fn realtime_command_is_hidden_when_realtime_is_disabled() {
let mut flags = all_enabled_flags();
@@ -297,6 +324,7 @@ mod tests {
SlashCommand::Diff,
SlashCommand::Mention,
SlashCommand::Status,
SlashCommand::Tokens,
]
);
}
+3
View File
@@ -411,6 +411,7 @@ mod status_controls;
mod status_surfaces;
mod streaming;
use self::status_surfaces::CachedProjectRootName;
mod tokens;
mod tool_lifecycle;
mod tool_requests;
mod transcript;
@@ -549,6 +550,8 @@ 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_outputs: Vec<(u64, tokens::TokenActivityHandle)>,
next_token_activity_request_id: u64,
plan_type: Option<PlanType>,
codex_rate_limit_reached_type: Option<RateLimitReachedType>,
rate_limit_warnings: RateLimitWarningState,
@@ -124,6 +124,8 @@ impl ChatWidget {
rate_limit_snapshots_by_limit_id: BTreeMap::new(),
refreshing_status_outputs: Vec::new(),
next_status_refresh_request_id: 0,
refreshing_token_activity_outputs: Vec::new(),
next_token_activity_request_id: 0,
plan_type: initial_plan_type,
codex_rate_limit_reached_type: None,
rate_limit_warnings: RateLimitWarningState::default(),
@@ -262,6 +264,9 @@ impl ChatWidget {
widget
.bottom_pane
.set_connectors_enabled(widget.connectors_enabled());
widget
.bottom_pane
.set_token_activity_command_enabled(widget.has_chatgpt_account);
widget.refresh_status_surfaces();
widget
+2
View File
@@ -233,6 +233,8 @@ impl ChatWidget {
self.has_chatgpt_account = has_chatgpt_account;
self.bottom_pane
.set_connectors_enabled(self.connectors_enabled());
self.bottom_pane
.set_token_activity_command_enabled(has_chatgpt_account);
}
pub(crate) fn set_realtime_audio_device(
@@ -35,6 +35,7 @@ const SIDE_SLASH_COMMAND_UNAVAILABLE_HINT: &str =
const GOAL_USAGE: &str = "Usage: /goal <objective>";
const GOAL_USAGE_HINT: &str = "Example: /goal improve benchmark coverage";
const RAW_USAGE: &str = "Usage: /raw [on|off]";
const TOKENS_CHATGPT_LOGIN_REQUIRED: &str = "Sign in with ChatGPT to use /tokens.";
impl ChatWidget {
/// Dispatch a bare slash command and record its staged local-history entry.
@@ -414,6 +415,11 @@ impl ChatWidget {
);
}
}
SlashCommand::Tokens => {
if self.ensure_token_activity_command_available() {
self.add_token_activity_output(tokens::TokenActivityView::Daily);
}
}
SlashCommand::Ide => {
self.handle_ide_command();
}
@@ -614,6 +620,16 @@ impl ChatWidget {
} = prepared;
let trimmed = args.trim();
match cmd {
SlashCommand::Tokens => {
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: /tokens [daily|weekly|cumulative]".to_string(),
),
}
}
}
SlashCommand::Ide => {
self.handle_ide_command_args(trimmed);
}
@@ -944,6 +960,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_chatgpt_account,
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),
@@ -954,6 +971,14 @@ impl ChatWidget {
}
}
fn ensure_token_activity_command_available(&mut self) -> bool {
if self.has_chatgpt_account {
return true;
}
self.add_error_message(TOKENS_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;
@@ -961,6 +986,7 @@ impl ChatWidget {
match cmd {
SlashCommand::Ide
| SlashCommand::Status
| SlashCommand::Tokens
| SlashCommand::DebugConfig
| SlashCommand::Ps
| SlashCommand::Stop
@@ -1136,6 +1136,67 @@ 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_tokens_command_reports_chatgpt_login_requirement() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
submit_composer_text(&mut chat, "/tokens");
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 /tokens."),
"expected ChatGPT login requirement, got: {rendered:?}"
);
assert_eq!(recall_latest_after_clearing(&mut chat), "/tokens");
}
#[tokio::test]
async fn signed_out_tokens_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, "/tokens 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 /tokens."),
"expected ChatGPT login requirement, got: {rendered:?}"
);
assert_eq!(recall_latest_after_clearing(&mut chat), "/tokens weekly");
}
#[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);
chat.dispatch_command(SlashCommand::Tokens);
assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_)));
let request_id = match rx.try_recv() {
Ok(AppEvent::RefreshTokenActivity { request_id }) => request_id,
other => panic!("expected token activity refresh request, got {other:?}"),
};
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 unrecognized_slash_command_is_not_added_to_local_recall() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
+839
View File
@@ -0,0 +1,839 @@
use std::collections::BTreeMap;
use std::sync::Arc;
use std::sync::RwLock;
use chrono::Datelike;
use chrono::Duration;
use chrono::NaiveDate;
use chrono::Utc;
use codex_app_server_protocol::GetAccountTokenUsageResponse;
use ratatui::style::Color;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use super::ChatWidget;
use crate::app_event::AppEvent;
use crate::color::blend;
use crate::history_cell::CompositeHistoryCell;
use crate::history_cell::HistoryCell;
use crate::history_cell::PlainHistoryCell;
use crate::history_cell::plain_lines;
use crate::render::highlight::foreground_style_for_scopes;
use crate::status::format_tokens_compact;
use crate::terminal_palette::StdoutColorLevel;
use crate::terminal_palette::best_color;
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 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;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum TokenActivityView {
Daily,
Weekly,
Cumulative,
}
impl TokenActivityView {
pub(super) 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,
}
}
fn label(self) -> &'static str {
match self {
Self::Daily => "Daily",
Self::Weekly => "Weekly",
Self::Cumulative => "Cumulative",
}
}
}
#[derive(Debug)]
enum TokenActivityState {
Loading,
Loaded(GetAccountTokenUsageResponse),
Error,
}
#[derive(Clone, Debug)]
pub(super) struct TokenActivityHandle {
state: Arc<RwLock<TokenActivityState>>,
}
impl TokenActivityHandle {
pub(super) fn finish(&self, result: Result<GetAccountTokenUsageResponse, String>) {
let state = match result {
Ok(response) => TokenActivityState::Loaded(response),
Err(_) => TokenActivityState::Error,
};
#[expect(clippy::expect_used)]
let mut current = self.state.write().expect("token activity state poisoned");
*current = state;
}
}
#[derive(Debug)]
struct TokenActivityHistoryCell {
view: TokenActivityView,
state: Arc<RwLock<TokenActivityState>>,
}
pub(super) fn new_token_activity_output(
view: TokenActivityView,
) -> (CompositeHistoryCell, TokenActivityHandle) {
let command = PlainHistoryCell::new(vec![
format!("/tokens {}", 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) => self.loaded_lines(response, width),
}
}
fn raw_lines(&self) -> Vec<Line<'static>> {
plain_lines(self.display_lines(u16::MAX))
}
}
impl TokenActivityHistoryCell {
fn loaded_lines(
&self,
response: &GetAccountTokenUsageResponse,
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(self.chart_lines(buckets, Utc::now().date_naive(), width));
lines
}
fn chart_lines(
&self,
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, self.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(self.view, row)];
for column in first_column..WEEK_COUNT {
if column > first_column {
spans.push(" ".into());
}
let index = column * DAY_COUNT + row;
if self.view == TokenActivityView::Daily
&& cell_date(today, index).is_some_and(|date| date > today)
{
spans.push(" ".into());
} else {
let style = if self.view == TokenActivityView::Daily {
palette.for_level(levels[index])
} else {
palette.for_bar_level(levels[index])
};
spans.push(Span::styled(palette.glyph(levels[index]), style));
}
}
lines.push(spans.into());
}
// Separate the calendar from the legend/footer below.
lines.push(Line::default());
match self.view {
TokenActivityView::Daily => lines.push(legend_line(&palette)),
TokenActivityView::Weekly | TokenActivityView::Cumulative => {
lines.push(bar_caption(self.view, &values))
}
}
lines.push(view_footer(self.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(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 `/tokens` 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()
}
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))
}
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 {
fn current() -> Self {
let fallback = [
Style::default().dim(),
Style::default().green().dim(),
Style::default().green(),
Style::default().light_green(),
Style::default().light_green().bold(),
];
let fallback_bar_style = Style::default().light_green();
let fallback_palette = || Self {
styles: fallback,
bar_style: fallback_bar_style,
uses_color: false,
};
let (Some(fg), Some(bg), Some(anchor)) = (default_fg(), default_bg(), theme_anchor_rgb())
else {
return fallback_palette();
};
if matches!(
stdout_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(color))
});
let bar_style = Style::default().fg(best_color(blend(anchor, bg, /*alpha*/ 0.78)));
Self {
styles,
bar_style,
uses_color: true,
}
}
fn for_level(&self, level: usize) -> Style {
self.styles[level.min(/*other*/ 4)]
}
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`. In truecolor we always use the filled
/// glyph and let color carry the intensity; in low-color we use the hollow
/// glyph for empty cells so they remain visible without a color gradient.
fn glyph(&self, level: usize) -> &'static str {
if self.uses_color || level > 0 {
ACTIVE_CELL_GLYPH
} else {
EMPTY_CELL_GLYPH
}
}
}
fn theme_anchor_rgb() -> Option<(u8, u8, u8)> {
match numeric_style().fg? {
Color::Rgb(r, g, b) => Some((r, g, b)),
_ => None,
}
}
impl ChatWidget {
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.refreshing_token_activity_outputs
.push((request_id, handle));
self.add_to_history(cell);
self.app_event_tx
.send(AppEvent::RefreshTokenActivity { request_id });
}
pub(crate) fn finish_token_activity_refresh(
&mut self,
request_id: u64,
result: Result<GetAccountTokenUsageResponse, String>,
) -> bool {
let mut remaining = Vec::with_capacity(self.refreshing_token_activity_outputs.len());
let mut result = Some(result);
let mut updated_any = false;
for (pending_request_id, handle) in self.refreshing_token_activity_outputs.drain(..) {
if pending_request_id == request_id {
#[expect(clippy::expect_used)]
handle.finish(result.take().expect("token activity result consumed once"));
updated_any = true;
} else {
remaining.push((pending_request_id, handle));
}
}
self.refreshing_token_activity_outputs = remaining;
if updated_any {
self.request_redraw();
}
updated_any
}
pub(crate) fn clear_pending_token_activity_refreshes(&mut self) {
self.refreshing_token_activity_outputs.clear();
}
}
#[cfg(test)]
mod tests {
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 cell = TokenActivityHistoryCell {
view: TokenActivityView::Daily,
state: Arc::new(RwLock::new(TokenActivityState::Loading)),
};
let rendered = cell
.chart_lines(&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 cell = TokenActivityHistoryCell {
view: TokenActivityView::Daily,
state: Arc::new(RwLock::new(TokenActivityState::Loading)),
};
let lines = cell.chart_lines(&[], 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 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
"
);
}
}
+5
View File
@@ -45,6 +45,7 @@ pub enum SlashCommand {
Diff,
Mention,
Status,
Tokens,
DebugConfig,
Title,
Statusline,
@@ -98,6 +99,7 @@ impl SlashCommand {
SlashCommand::Skills => "use skills to improve how Codex performs specific tasks",
SlashCommand::Hooks => "view and manage lifecycle hooks",
SlashCommand::Status => "show current session configuration and token usage",
SlashCommand::Tokens => "show account token 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",
@@ -157,6 +159,7 @@ impl SlashCommand {
| SlashCommand::Keymap
| SlashCommand::Mcp
| SlashCommand::Raw
| SlashCommand::Tokens
| SlashCommand::Pets
| SlashCommand::Side
| SlashCommand::Btw
@@ -174,6 +177,7 @@ impl SlashCommand {
| SlashCommand::Diff
| SlashCommand::Mention
| SlashCommand::Status
| SlashCommand::Tokens
| SlashCommand::Ide
)
}
@@ -210,6 +214,7 @@ impl SlashCommand {
| SlashCommand::Skills
| SlashCommand::Hooks
| SlashCommand::Status
| SlashCommand::Tokens
| SlashCommand::DebugConfig
| SlashCommand::Ps
| SlashCommand::Stop
+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();