mirror of
https://github.com/openai/codex.git
synced 2026-11-12 21:20:34 +00:00
feat(tui): add token activity command
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(());
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user