mirror of
https://github.com/openai/codex.git
synced 2026-11-04 08:25:29 +00:00
## 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.
393 lines
13 KiB
Rust
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}"));
|
|
}
|