Files
codex/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs
T
efrazer-oai c8e5db16c9 feat: show enterprise monthly credit limits in status (#24812)
## Summary

Enterprise users can have an effective monthly credit limit, but Codex
`/status` currently drops that metadata from the account-usage response.

This change adds the optional `spend_control.individual_limit`
projection to the existing rate-limit snapshot flow. The backend client
reads the monthly limit, app-server exposes it as `individualLimit`, and
the TUI renders a `Monthly credit limit` row through the existing
progress-bar renderer.

When the backend does not return an effective monthly limit, existing
rate-limit behavior is unchanged.

## Existing backend state

The account-usage backend already returns the effective monthly limit
and current usage together:

```json
{
  "spend_control": {
    "reached": false,
    "individual_limit": {
      "limit": "25000",
      "used": "8000",
      "remaining": "17000",
      "used_percent": 32,
      "remaining_percent": 68,
      "reset_after_seconds": 86400,
      "reset_at": 1778137680
    }
  }
}
```

Before this change, Codex projected rolling `primary` and `secondary`
windows plus `credits`. It ignored `spend_control.individual_limit`, so
app-server clients and `/status` could not render the monthly cap.

The updated flow is:

```text
account usage backend
  -> backend-client reads spend_control.individual_limit
  -> existing rate-limit snapshot carries optional individual_limit
  -> app-server exposes optional individualLimit
  -> TUI renders Monthly credit limit
```

## App-server contract

`account/rateLimits/read` and sparse `account/rateLimits/updated`
notifications now include an additive nullable
`rateLimits.individualLimit` field:

```json
{
  "individualLimit": {
    "limit": "25000",
    "used": "8000",
    "remainingPercent": 68,
    "resetsAt": 1778137680
  }
}
```

In an `account/rateLimits/read` response, `null` means no monthly limit
is available. `account/rateLimits/updated` remains a sparse rolling
notification: clients merge available values into their most recent
`account/rateLimits/read` snapshot or refetch. Nullable account metadata
in a rolling notification does not clear a previously observed value.

## Design decisions

- Extend the existing rate-limit snapshot instead of introducing a
separate request or wire-level update protocol.
- Keep the Codex projection narrow: `/status` needs the effective limit,
current usage, remaining percentage, and reset timestamp.
- Render the monthly row through the existing progress-bar renderer,
with one optional detail line for `8,000 of 25,000 credits used`.
- Keep the backend response optional so existing accounts and older
usage states preserve their current behavior.
- Preserve cached monthly metadata when sparse rolling notifications
omit it. Live account-usage reads remain authoritative and can clear a
removed limit.

## Visual evidence

```text
 Monthly credit limit:   [██████████████░░░░░░] 68% left (resets 07:08 on 7 May)
                         8,000 of 25,000 credits used
```

Snapshot:
`codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_enterprise_monthly_credit_limit.snap`

## Testing

Tests: generated app-server schema verification, protocol tests,
backend-client tests, app-server integration coverage, TUI snapshot
coverage, formatting, and workspace lint cleanup.
2026-06-01 21:25:42 -07:00

393 lines
13 KiB
Rust

use super::*;
use crate::bottom_pane::preview_line_for_title_items;
use pretty_assertions::assert_eq;
use ratatui::text::Line;
fn line_text(line: Line<'static>) -> String {
line.spans
.into_iter()
.map(|span| span.content.into_owned())
.collect()
}
fn status_preview_line_option(chat: &mut ChatWidget, items: &[StatusLineItem]) -> Option<String> {
let preview_data = chat.status_surface_preview_data();
preview_data
.status_line_for_items(items.iter().copied(), /*use_theme_colors*/ true)
.map(line_text)
}
fn status_preview_line(chat: &mut ChatWidget, items: &[StatusLineItem]) -> String {
status_preview_line_option(chat, items).expect("status preview line")
}
fn title_preview_line(chat: &mut ChatWidget, items: &[TerminalTitleItem]) -> String {
let preview_data = chat.terminal_title_preview_data();
let preview =
preview_line_for_title_items(items, &preview_data).expect("terminal title preview line");
line_text(preview)
}
fn combined_preview_snapshot(
chat: &mut ChatWidget,
status_items: &[StatusLineItem],
title_items: &[TerminalTitleItem],
) -> String {
normalize_snapshot_paths(format!(
"status line: {}\nterminal title: {}",
status_preview_line(chat, status_items),
title_preview_line(chat, title_items),
))
}
fn status_line_popup_snapshot(chat: &mut ChatWidget) -> String {
chat.open_status_line_setup();
normalize_snapshot_paths(strip_osc8_for_snapshot(&render_bottom_popup(
chat, /*width*/ 100,
)))
}
fn terminal_title_popup_snapshot(chat: &mut ChatWidget) -> String {
chat.open_terminal_title_setup();
normalize_snapshot_paths(strip_osc8_for_snapshot(&render_bottom_popup(
chat, /*width*/ 100,
)))
}
fn cache_project_root(chat: &mut ChatWidget, root_name: &str) {
chat.status_line_project_root_name_cache = Some(CachedProjectRootName {
cwd: chat.config.cwd.to_path_buf(),
root_name: Some(root_name.to_string()),
});
}
fn cache_rate_limit_snapshot(chat: &mut ChatWidget) {
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
limit_id: None,
limit_name: None,
primary: Some(RateLimitWindow {
used_percent: 35,
window_duration_mins: Some(30 * 24 * 60),
resets_at: None,
}),
secondary: Some(RateLimitWindow {
used_percent: 50,
window_duration_mins: Some(7 * 24 * 60),
resets_at: None,
}),
credits: None,
individual_limit: None,
plan_type: None,
rate_limit_reached_type: None,
}));
}
#[tokio::test]
async fn status_surface_preview_lines_live_only_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
cache_project_root(&mut chat, "preview-live-root");
chat.status_line_branch = Some("feature/live-preview-branch".to_string());
chat.thread_name = Some("Live preview thread".to_string());
chat.transcript.last_plan_progress = Some((2, 5));
let snapshot = combined_preview_snapshot(
&mut chat,
&[
StatusLineItem::ProjectRoot,
StatusLineItem::GitBranch,
StatusLineItem::ThreadTitle,
],
&[
TerminalTitleItem::Project,
TerminalTitleItem::Thread,
TerminalTitleItem::GitBranch,
TerminalTitleItem::TaskProgress,
],
);
assert_chatwidget_snapshot!("status_surface_previews_live_only", snapshot);
}
#[tokio::test]
async fn status_line_setup_popup_live_only_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
cache_project_root(&mut chat, "preview-live-root");
chat.status_line_branch = Some("feature/live-preview-branch".to_string());
chat.thread_name = Some("Live preview thread".to_string());
chat.config.tui_status_line = Some(vec![
"project-name".to_string(),
"git-branch".to_string(),
"thread-title".to_string(),
]);
assert_chatwidget_snapshot!(
"status_line_setup_popup_live_only",
status_line_popup_snapshot(&mut chat)
);
}
#[tokio::test]
async fn status_surface_preview_lines_hardcoded_only_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let snapshot = combined_preview_snapshot(
&mut chat,
&[
StatusLineItem::ProjectRoot,
StatusLineItem::GitBranch,
StatusLineItem::ThreadTitle,
StatusLineItem::Permissions,
StatusLineItem::ApprovalMode,
],
&[
TerminalTitleItem::Thread,
TerminalTitleItem::GitBranch,
TerminalTitleItem::TaskProgress,
],
);
assert_chatwidget_snapshot!("status_surface_previews_hardcoded_only", snapshot);
}
#[tokio::test]
async fn thread_title_falls_back_to_thread_id_when_unnamed() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
assert_eq!(
status_preview_line(&mut chat, &[StatusLineItem::ThreadTitle]),
thread_id.to_string()
);
assert_eq!(
title_preview_line(&mut chat, &[TerminalTitleItem::Thread]),
thread_id.to_string()
);
}
#[tokio::test]
async fn status_line_setup_popup_hardcoded_only_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.config.tui_status_line = Some(vec![
"project-name".to_string(),
"git-branch".to_string(),
"thread-title".to_string(),
]);
assert_chatwidget_snapshot!(
"status_line_setup_popup_hardcoded_only",
status_line_popup_snapshot(&mut chat)
);
}
#[tokio::test]
async fn status_surface_preview_lines_mixed_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.status_line_branch = Some("feature/mixed-preview".to_string());
chat.thread_name = Some("Mixed preview thread".to_string());
let snapshot = combined_preview_snapshot(
&mut chat,
&[
StatusLineItem::ProjectRoot,
StatusLineItem::GitBranch,
StatusLineItem::ThreadTitle,
],
&[
TerminalTitleItem::Project,
TerminalTitleItem::Thread,
TerminalTitleItem::TaskProgress,
],
);
assert_chatwidget_snapshot!("status_surface_previews_mixed", snapshot);
}
#[tokio::test]
async fn status_surface_preview_lines_rate_limits_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
cache_rate_limit_snapshot(&mut chat);
let snapshot = combined_preview_snapshot(
&mut chat,
&[StatusLineItem::FiveHourLimit, StatusLineItem::WeeklyLimit],
&[
TerminalTitleItem::FiveHourLimit,
TerminalTitleItem::WeeklyLimit,
],
);
assert_chatwidget_snapshot!("status_surface_previews_rate_limits", snapshot);
}
#[tokio::test]
async fn status_surface_preview_omits_unavailable_rate_limit_items() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
limit_id: None,
limit_name: None,
primary: Some(RateLimitWindow {
used_percent: 9,
window_duration_mins: Some(7 * 24 * 60),
resets_at: None,
}),
secondary: None,
credits: None,
individual_limit: None,
plan_type: None,
rate_limit_reached_type: None,
}));
assert_eq!(
status_preview_line_option(&mut chat, &[StatusLineItem::FiveHourLimit]),
None
);
assert_eq!(
status_preview_line(
&mut chat,
&[StatusLineItem::FiveHourLimit, StatusLineItem::WeeklyLimit]
),
"weekly 91% left"
);
assert_eq!(
title_preview_line(
&mut chat,
&[
TerminalTitleItem::FiveHourLimit,
TerminalTitleItem::WeeklyLimit
],
),
"weekly 91% left"
);
}
#[tokio::test]
async fn status_line_setup_popup_rate_limits_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
cache_rate_limit_snapshot(&mut chat);
chat.config.tui_status_line = Some(vec![
"five-hour-limit".to_string(),
"weekly-limit".to_string(),
]);
assert_chatwidget_snapshot!(
"status_line_setup_popup_rate_limits",
status_line_popup_snapshot(&mut chat)
);
}
#[tokio::test]
async fn status_line_setup_popup_mixed_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.status_line_branch = Some("feature/mixed-preview".to_string());
chat.thread_name = Some("Mixed preview thread".to_string());
chat.config.tui_status_line = Some(vec![
"project-name".to_string(),
"git-branch".to_string(),
"thread-title".to_string(),
]);
assert_chatwidget_snapshot!(
"status_line_setup_popup_mixed",
status_line_popup_snapshot(&mut chat)
);
}
#[tokio::test]
async fn terminal_title_setup_popup_live_only_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
cache_project_root(&mut chat, "preview-live-root");
chat.status_line_branch = Some("feature/live-preview-branch".to_string());
chat.thread_name = Some("Live preview thread".to_string());
chat.transcript.last_plan_progress = Some((2, 5));
chat.config.tui_terminal_title = Some(vec![
"project-name".to_string(),
"thread-title".to_string(),
"git-branch".to_string(),
"task-progress".to_string(),
]);
assert_chatwidget_snapshot!(
"terminal_title_setup_popup_live_only",
terminal_title_popup_snapshot(&mut chat)
);
}
#[tokio::test]
async fn terminal_title_setup_popup_hardcoded_only_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.config.tui_terminal_title = Some(vec![
"thread-title".to_string(),
"git-branch".to_string(),
"task-progress".to_string(),
]);
assert_chatwidget_snapshot!(
"terminal_title_setup_popup_hardcoded_only",
terminal_title_popup_snapshot(&mut chat)
);
}
#[tokio::test]
async fn terminal_title_setup_popup_mixed_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_name = Some("Mixed preview thread".to_string());
chat.config.tui_terminal_title = Some(vec![
"project-name".to_string(),
"thread-title".to_string(),
"task-progress".to_string(),
]);
assert_chatwidget_snapshot!(
"terminal_title_setup_popup_mixed",
terminal_title_popup_snapshot(&mut chat)
);
}
#[tokio::test]
async fn terminal_title_setup_popup_rate_limits_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
cache_rate_limit_snapshot(&mut chat);
chat.config.tui_terminal_title = Some(vec![
"five-hour-limit".to_string(),
"weekly-limit".to_string(),
]);
assert_chatwidget_snapshot!(
"terminal_title_setup_popup_rate_limits",
terminal_title_popup_snapshot(&mut chat)
);
}
#[tokio::test]
async fn missing_project_root_uses_different_status_and_title_preview_sources() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let status_preview = status_preview_line(&mut chat, &[StatusLineItem::ProjectRoot]);
let title_preview = title_preview_line(&mut chat, &[TerminalTitleItem::Project]);
assert_eq!(status_preview, "my-project");
assert_eq!(title_preview, "project");
}
#[tokio::test]
async fn terminal_title_preview_uses_title_truncation_for_live_values() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let long_thread = "This thread title is intentionally much longer than forty-eight characters";
let long_branch = "feature/this-branch-name-is-intentionally-longer-than-thirty-two";
chat.thread_name = Some(long_thread.to_string());
chat.status_line_branch = Some(long_branch.to_string());
let preview = title_preview_line(
&mut chat,
&[TerminalTitleItem::Thread, TerminalTitleItem::GitBranch],
);
let truncated_thread =
ChatWidget::truncate_terminal_title_part(long_thread.to_string(), /*max_chars*/ 48);
let truncated_branch =
ChatWidget::truncate_terminal_title_part(long_branch.to_string(), /*max_chars*/ 32);
assert_eq!(preview, format!("{truncated_thread} | {truncated_branch}"));
}