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