Show estimated thread usage in /status (#38281)

## What changed

- Extend `account/usage/read` with an optional `threadId` request and a
  backward-compatible `threadUsage` response containing estimated credits,
  optional USD cost, and model, reasoning, speed, and token breakdowns.
- Fetch thread usage for eligible Business and Enterprise plans when `/status`
  is opened, then update the rendered status card asynchronously.
- Preserve terminal scrollback when replacing a visible status-card tail, and
  append a refreshed card when the original tail can no longer be updated.

## Testing

- Add app-server coverage for authentication, canonical thread IDs,
  unavailable billing routes, and malformed IDs.
- Add TUI coverage for usage formatting, stale responses, retries, history
  updates, terminal reflow, and scrollback preservation.

GitOrigin-RevId: 49300914b3fba917c0f08e8595363e04e5ba6d37
This commit is contained in:
Felipe Coury
2026-08-13 00:30:08 +00:00
committed by copyberry
parent 27a98dde4d
commit f1a1fce26a
55 changed files with 2903 additions and 60 deletions
+20 -1
View File
@@ -1503,6 +1503,18 @@
},
"type": "object"
},
"GetAccountTokenUsageParams": {
"properties": {
"threadId": {
"description": "When present, read estimated usage for this thread instead of account-wide token activity.",
"type": [
"string",
"null"
]
}
},
"type": "object"
},
"HookMigration": {
"properties": {
"name": {
@@ -7262,7 +7274,14 @@
"type": "string"
},
"params": {
"type": "null"
"anyOf": [
{
"$ref": "#/definitions/GetAccountTokenUsageParams"
},
{
"type": "null"
}
]
}
},
"required": [
@@ -2062,7 +2062,14 @@
"type": "string"
},
"params": {
"type": "null"
"anyOf": [
{
"$ref": "#/definitions/v2/GetAccountTokenUsageParams"
},
{
"type": "null"
}
]
}
},
"required": [
@@ -11377,6 +11384,18 @@
"title": "GetAccountResponse",
"type": "object"
},
"GetAccountTokenUsageParams": {
"properties": {
"threadId": {
"description": "When present, read estimated usage for this thread instead of account-wide token activity.",
"type": [
"string",
"null"
]
}
},
"type": "object"
},
"GetAccountTokenUsageResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -11391,6 +11410,18 @@
},
"summary": {
"$ref": "#/definitions/v2/AccountTokenUsageSummary"
},
"threadUsage": {
"anyOf": [
{
"$ref": "#/definitions/v2/ThreadUsage"
},
{
"type": "null"
}
],
"default": null,
"description": "Estimated usage when a thread was requested and its billing route is available."
}
},
"required": [
@@ -14253,6 +14284,18 @@
],
"type": "string"
},
"NullableGetAccountTokenUsageParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"anyOf": [
{
"$ref": "#/definitions/v2/GetAccountTokenUsageParams"
},
{
"type": "null"
}
],
"title": "Nullable_GetAccountTokenUsageParams"
},
"OverriddenMetadata": {
"properties": {
"effectiveValue": true,
@@ -21817,6 +21860,101 @@
],
"type": "string"
},
"ThreadUsage": {
"properties": {
"estimatedUsageCreditsMicros": {
"format": "int64",
"type": "integer"
},
"estimatedUsageUsdMicros": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"groups": {
"items": {
"$ref": "#/definitions/v2/ThreadUsageBreakdownGroup"
},
"type": "array"
},
"threadId": {
"type": "string"
}
},
"required": [
"estimatedUsageCreditsMicros",
"groups",
"threadId"
],
"type": "object"
},
"ThreadUsageBreakdownGroup": {
"properties": {
"cachedInputTokens": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"estimatedUsageCreditsMicros": {
"format": "int64",
"type": "integer"
},
"inputTokens": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"model": {
"type": [
"string",
"null"
]
},
"netNewInputTokens": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"outputTokens": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"reasoningEffort": {
"type": [
"string",
"null"
]
},
"speed": {
"type": [
"string",
"null"
]
},
"totalTokens": {
"format": "int64",
"type": [
"integer",
"null"
]
}
},
"required": [
"estimatedUsageCreditsMicros"
],
"type": "object"
},
"TokenUsageBreakdown": {
"properties": {
"cacheWriteInputTokens": {
@@ -3252,7 +3252,14 @@
"type": "string"
},
"params": {
"type": "null"
"anyOf": [
{
"$ref": "#/definitions/GetAccountTokenUsageParams"
},
{
"type": "null"
}
]
}
},
"required": [
@@ -7646,6 +7653,18 @@
"title": "GetAccountResponse",
"type": "object"
},
"GetAccountTokenUsageParams": {
"properties": {
"threadId": {
"description": "When present, read estimated usage for this thread instead of account-wide token activity.",
"type": [
"string",
"null"
]
}
},
"type": "object"
},
"GetAccountTokenUsageResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -7660,6 +7679,18 @@
},
"summary": {
"$ref": "#/definitions/AccountTokenUsageSummary"
},
"threadUsage": {
"anyOf": [
{
"$ref": "#/definitions/ThreadUsage"
},
{
"type": "null"
}
],
"default": null,
"description": "Estimated usage when a thread was requested and its billing route is available."
}
},
"required": [
@@ -10583,6 +10614,18 @@
],
"type": "string"
},
"NullableGetAccountTokenUsageParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"anyOf": [
{
"$ref": "#/definitions/GetAccountTokenUsageParams"
},
{
"type": "null"
}
],
"title": "Nullable_GetAccountTokenUsageParams"
},
"OverriddenMetadata": {
"properties": {
"effectiveValue": true,
@@ -19569,6 +19612,101 @@
],
"type": "string"
},
"ThreadUsage": {
"properties": {
"estimatedUsageCreditsMicros": {
"format": "int64",
"type": "integer"
},
"estimatedUsageUsdMicros": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"groups": {
"items": {
"$ref": "#/definitions/ThreadUsageBreakdownGroup"
},
"type": "array"
},
"threadId": {
"type": "string"
}
},
"required": [
"estimatedUsageCreditsMicros",
"groups",
"threadId"
],
"type": "object"
},
"ThreadUsageBreakdownGroup": {
"properties": {
"cachedInputTokens": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"estimatedUsageCreditsMicros": {
"format": "int64",
"type": "integer"
},
"inputTokens": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"model": {
"type": [
"string",
"null"
]
},
"netNewInputTokens": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"outputTokens": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"reasoningEffort": {
"type": [
"string",
"null"
]
},
"speed": {
"type": [
"string",
"null"
]
},
"totalTokens": {
"format": "int64",
"type": [
"integer",
"null"
]
}
},
"required": [
"estimatedUsageCreditsMicros"
],
"type": "object"
},
"TokenUsageBreakdown": {
"properties": {
"cacheWriteInputTokens": {
@@ -56,6 +56,101 @@
}
},
"type": "object"
},
"ThreadUsage": {
"properties": {
"estimatedUsageCreditsMicros": {
"format": "int64",
"type": "integer"
},
"estimatedUsageUsdMicros": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"groups": {
"items": {
"$ref": "#/definitions/ThreadUsageBreakdownGroup"
},
"type": "array"
},
"threadId": {
"type": "string"
}
},
"required": [
"estimatedUsageCreditsMicros",
"groups",
"threadId"
],
"type": "object"
},
"ThreadUsageBreakdownGroup": {
"properties": {
"cachedInputTokens": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"estimatedUsageCreditsMicros": {
"format": "int64",
"type": "integer"
},
"inputTokens": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"model": {
"type": [
"string",
"null"
]
},
"netNewInputTokens": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"outputTokens": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"reasoningEffort": {
"type": [
"string",
"null"
]
},
"speed": {
"type": [
"string",
"null"
]
},
"totalTokens": {
"format": "int64",
"type": [
"integer",
"null"
]
}
},
"required": [
"estimatedUsageCreditsMicros"
],
"type": "object"
}
},
"properties": {
@@ -70,6 +165,18 @@
},
"summary": {
"$ref": "#/definitions/AccountTokenUsageSummary"
},
"threadUsage": {
"anyOf": [
{
"$ref": "#/definitions/ThreadUsage"
},
{
"type": "null"
}
],
"default": null,
"description": "Estimated usage when a thread was requested and its billing route is available."
}
},
"required": [
@@ -0,0 +1,26 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"anyOf": [
{
"$ref": "#/definitions/GetAccountTokenUsageParams"
},
{
"type": "null"
}
],
"definitions": {
"GetAccountTokenUsageParams": {
"properties": {
"threadId": {
"description": "When present, read estimated usage for this thread instead of account-wide token activity.",
"type": [
"string",
"null"
]
}
},
"type": "object"
}
},
"title": "Nullable_GetAccountTokenUsageParams"
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,9 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type GetAccountTokenUsageParams = {
/**
* When present, read estimated usage for this thread instead of account-wide token activity.
*/
threadId?: string | null, };
@@ -3,5 +3,10 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AccountTokenUsageDailyBucket } from "./AccountTokenUsageDailyBucket";
import type { AccountTokenUsageSummary } from "./AccountTokenUsageSummary";
import type { ThreadUsage } from "./ThreadUsage";
export type GetAccountTokenUsageResponse = { summary: AccountTokenUsageSummary, dailyUsageBuckets: Array<AccountTokenUsageDailyBucket> | null, };
export type GetAccountTokenUsageResponse = { summary: AccountTokenUsageSummary, dailyUsageBuckets: Array<AccountTokenUsageDailyBucket> | null,
/**
* Estimated usage when a thread was requested and its billing route is available.
*/
threadUsage?: ThreadUsage | null, };
@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ThreadUsageBreakdownGroup } from "./ThreadUsageBreakdownGroup";
export type ThreadUsage = { threadId: string, estimatedUsageCreditsMicros: bigint, estimatedUsageUsdMicros: bigint | null, groups: Array<ThreadUsageBreakdownGroup>, };
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ThreadUsageBreakdownGroup = { model: string | null, reasoningEffort: string | null, speed: string | null, estimatedUsageCreditsMicros: bigint, netNewInputTokens: bigint | null, cachedInputTokens: bigint | null, inputTokens: bigint | null, outputTokens: bigint | null, totalTokens: bigint | null, };
@@ -175,6 +175,7 @@ export type { FsWriteFileResponse } from "./FsWriteFileResponse";
export type { GetAccountParams } from "./GetAccountParams";
export type { GetAccountRateLimitsResponse } from "./GetAccountRateLimitsResponse";
export type { GetAccountResponse } from "./GetAccountResponse";
export type { GetAccountTokenUsageParams } from "./GetAccountTokenUsageParams";
export type { GetAccountTokenUsageResponse } from "./GetAccountTokenUsageResponse";
export type { GetWorkspaceMessagesResponse } from "./GetWorkspaceMessagesResponse";
export type { GitInfo } from "./GitInfo";
@@ -516,6 +517,8 @@ export type { ThreadUnarchivedNotification } from "./ThreadUnarchivedNotificatio
export type { ThreadUnsubscribeParams } from "./ThreadUnsubscribeParams";
export type { ThreadUnsubscribeResponse } from "./ThreadUnsubscribeResponse";
export type { ThreadUnsubscribeStatus } from "./ThreadUnsubscribeStatus";
export type { ThreadUsage } from "./ThreadUsage";
export type { ThreadUsageBreakdownGroup } from "./ThreadUsageBreakdownGroup";
export type { TokenUsageBreakdown } from "./TokenUsageBreakdown";
export type { ToolRequestUserInputAnswer } from "./ToolRequestUserInputAnswer";
export type { ToolRequestUserInputOption } from "./ToolRequestUserInputOption";
+23 -2
View File
@@ -2145,6 +2145,17 @@ mod tests {
client_request_ts.contains("MockExperimentalMethodParams"),
false
);
const LEGACY_ACCOUNT_USAGE_REQUEST: &str = concat!(
"{ \"method\": \"account/usage/read\", id: RequestId, ",
"params?: GetAccountTokenUsageParams | undefined, }"
);
assert!(client_request_ts.contains(LEGACY_ACCOUNT_USAGE_REQUEST));
let account_usage_response_ts = std::str::from_utf8(
fixture_tree
.get(Path::new("v2/GetAccountTokenUsageResponse.ts"))
.ok_or_else(|| anyhow::anyhow!("missing account usage response fixture"))?,
)?;
assert!(account_usage_response_ts.contains("threadUsage?: ThreadUsage | null"));
let server_request_ts = std::str::from_utf8(
fixture_tree
.get(Path::new("ServerRequest.ts"))
@@ -2217,7 +2228,12 @@ mod tests {
});
let contents = std::str::from_utf8(contents)?;
if contents.contains("| undefined") {
// The stable usage RPC originally required `params: undefined`. Keep that exact
// legacy value accepted while extending the same method with optional thread params.
let legacy_account_usage_undefined = path == Path::new("ClientRequest.ts")
&& contents.matches("| undefined").count() == 1
&& contents.contains(LEGACY_ACCOUNT_USAGE_REQUEST);
if contents.contains("| undefined") && !legacy_account_usage_undefined {
undefined_offenders.push(path.clone());
}
@@ -2329,9 +2345,14 @@ mod tests {
// If the last non-whitespace before ':' is '?', then this is an
// optional field with a nullable type (i.e., "?: T | null").
// These are only allowed in *Params types.
// These are only allowed in *Params types, except the additive stable usage
// response field, which older servers omit and newer servers return as null.
let legacy_account_usage_response = path
== Path::new("v2/GetAccountTokenUsageResponse.ts")
&& field_prefix.trim() == "threadUsage?";
if field_prefix.chars().rev().find(|c| !c.is_whitespace()) == Some('?')
&& !allow_optional_nullable
&& !legacy_account_usage_response
{
let line_number =
contents[..abs_idx].chars().filter(|c| *c == '\n').count() + 1;
@@ -471,6 +471,19 @@ macro_rules! client_response_payload_from_impl {
($variant:ident, $response:ty, manual) => {};
}
/// Preserve explicit `undefined` accepted by the original stable usage request.
///
/// A Rust-based TypeScript proxy retains dependency discovery; a raw `#[ts(type = ...)]`
/// override would silently omit the generated params import and schema fixture.
#[allow(dead_code)]
#[derive(TS)]
#[ts(untagged)]
enum GetAccountTokenUsageParamsTypeScript {
Params(v2::GetAccountTokenUsageParams),
#[ts(type = "undefined")]
Undefined,
}
client_request_definitions! {
Initialize => "initialize" {
params: v1::InitializeParams,
@@ -1095,7 +1108,7 @@ client_request_definitions! {
},
GetAccountTokenUsage => "account/usage/read" {
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
params: #[ts(optional, as = "Option<GetAccountTokenUsageParamsTypeScript>", inline)] #[serde(default, skip_serializing_if = "Option::is_none")] v2::NullableGetAccountTokenUsageParams,
serialization: None,
response: v2::GetAccountTokenUsageResponse,
},
@@ -2850,6 +2863,56 @@ mod tests {
Ok(())
}
#[test]
fn serialize_get_account_thread_usage() -> Result<()> {
let request = ClientRequest::GetAccountTokenUsage {
request_id: RequestId::Integer(1),
params: Some(v2::GetAccountTokenUsageParams {
thread_id: Some("thread-123".to_string()),
}),
};
assert_eq!(
json!({
"method": "account/usage/read",
"id": 1,
"params": { "threadId": "thread-123" },
}),
serde_json::to_value(&request)?,
);
Ok(())
}
#[test]
fn deserialize_legacy_get_account_token_usage_response() -> Result<()> {
let response: v2::GetAccountTokenUsageResponse = serde_json::from_value(json!({
"summary": {
"lifetimeTokens": null,
"peakDailyTokens": null,
"longestRunningTurnSec": null,
"currentStreakDays": null,
"longestStreakDays": null,
},
"dailyUsageBuckets": null,
}))?;
assert_eq!(
response,
v2::GetAccountTokenUsageResponse {
summary: v2::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,
thread_usage: None,
},
);
assert_eq!(serde_json::to_value(response)?["threadUsage"], json!(null));
Ok(())
}
#[test]
fn serialize_get_workspace_messages() -> Result<()> {
let request = ClientRequest::GetWorkspaceMessages {
@@ -1,3 +1,4 @@
use super::ThreadUsage;
use crate::JsonSchema;
use crate::TS;
use crate::protocol::common::AuthMode;
@@ -383,12 +384,27 @@ pub enum ConsumeAccountRateLimitResetCreditOutcome {
AlreadyRedeemed,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct GetAccountTokenUsageParams {
/// When present, read estimated usage for this thread instead of account-wide token activity.
#[ts(optional = nullable)]
pub thread_id: Option<String>,
}
pub type NullableGetAccountTokenUsageParams = Option<GetAccountTokenUsageParams>;
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct GetAccountTokenUsageResponse {
pub summary: AccountTokenUsageSummary,
pub daily_usage_buckets: Option<Vec<AccountTokenUsageDailyBucket>>,
/// Estimated usage when a thread was requested and its billing route is available.
#[serde(default)]
#[ts(optional, as = "Option<Option<ThreadUsage>>")]
pub thread_usage: Option<ThreadUsage>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
@@ -26,6 +26,7 @@ mod remote_control;
mod review;
mod thread;
mod thread_data;
mod thread_usage;
mod turn;
mod windows_sandbox;
@@ -56,6 +57,7 @@ pub use review::*;
pub use shared::*;
pub use thread::*;
pub use thread_data::*;
pub use thread_usage::*;
pub use turn::*;
pub use windows_sandbox::*;
@@ -0,0 +1,29 @@
use crate::JsonSchema;
use crate::TS;
use serde::Deserialize;
use serde::Serialize;
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadUsage {
pub thread_id: String,
pub estimated_usage_credits_micros: i64,
pub estimated_usage_usd_micros: Option<i64>,
pub groups: Vec<ThreadUsageBreakdownGroup>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadUsageBreakdownGroup {
pub model: Option<String>,
pub reasoning_effort: Option<String>,
pub speed: Option<String>,
pub estimated_usage_credits_micros: i64,
pub net_new_input_tokens: Option<i64>,
pub cached_input_tokens: Option<i64>,
pub input_tokens: Option<i64>,
pub output_tokens: Option<i64>,
pub total_tokens: Option<i64>,
}
+1 -1
View File
@@ -2211,7 +2211,7 @@ Codex supports these authentication modes. The current mode is surfaced in `acco
- `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `bedrockApiKey`, `chatgpt`, `personalAccessToken`, or `null`) and includes the current ChatGPT `planType` when available.
- `account/rateLimits/read` — fetch ChatGPT rate limits, an optional effective monthly credit limit, whether spend control has been reached, and the earned rate-limit resets currently available, including expiry details when provided by the backend. Rate-limit updates arrive via `account/rateLimits/updated` (notify); reset-credit data is snapshot-only.
- `account/rateLimitResetCredit/consume` — consume one earned reset using a caller-provided idempotency key, optionally selecting a reset-credit ID returned by `account/rateLimits/read`.
- `account/usage/read` — fetch ChatGPT account token-activity summary and daily buckets.
- `account/usage/read` — fetch ChatGPT account token-activity summary and daily buckets, or pass a valid thread UUID as `threadId` to read estimated credits, optional cost, and usage breakdowns for one thread using the app-server's active account. The optional `threadUsage` response field is absent on older servers and `null` when the billing route is unavailable.
- `account/workspaceMessages/read` — fetch active workspace messages, including workspace notification headlines when available.
- `account/rateLimits/updated` (notify) — emitted whenever a user's ChatGPT rate limits change. This is a sparse rolling update; merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot.
`spendControlReached` is `true` or `false` when the backend reports spend-control state; `null` means unavailable and must not clear a previously observed value in a sparse update.
+2 -2
View File
@@ -1409,8 +1409,8 @@ impl MessageProcessor {
.consume_account_rate_limit_reset_credit(params)
.await
}
ClientRequest::GetAccountTokenUsage { .. } => {
self.account_processor.get_account_token_usage().await
ClientRequest::GetAccountTokenUsage { params, .. } => {
self.account_processor.get_account_token_usage(params).await
}
ClientRequest::GetWorkspaceMessages { .. } => {
self.account_processor.get_workspace_messages().await
@@ -80,6 +80,7 @@ use codex_app_server_protocol::FeedbackUploadResponse;
use codex_app_server_protocol::GetAccountParams;
use codex_app_server_protocol::GetAccountRateLimitsResponse;
use codex_app_server_protocol::GetAccountResponse;
use codex_app_server_protocol::GetAccountTokenUsageParams;
use codex_app_server_protocol::GetAccountTokenUsageResponse;
use codex_app_server_protocol::GetAuthStatusParams;
use codex_app_server_protocol::GetAuthStatusResponse;
@@ -13,6 +13,7 @@ mod rate_limit_resets;
// Duration before a browser ChatGPT login attempt is abandoned.
const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60);
const ACCOUNT_TOKEN_USAGE_FETCH_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 10);
const THREAD_USAGE_FETCH_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 60);
const ACCOUNT_WORKSPACE_MESSAGES_FETCH_TIMEOUT: Duration =
Duration::from_millis(/*millis*/ 1000);
// Login overrides are intentionally available only in debug builds.
@@ -148,8 +149,9 @@ impl AccountRequestProcessor {
pub(crate) async fn get_account_token_usage(
&self,
params: Option<GetAccountTokenUsageParams>,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.get_account_token_usage_response()
self.get_account_token_usage_response(params)
.await
.map(|response| Some(response.into()))
}
@@ -1123,7 +1125,16 @@ impl AccountRequestProcessor {
async fn get_account_token_usage_response(
&self,
params: Option<GetAccountTokenUsageParams>,
) -> Result<GetAccountTokenUsageResponse, JSONRPCErrorError> {
let thread_id = params
.and_then(|params| params.thread_id)
.map(|thread_id| {
ThreadId::from_string(&thread_id)
.map_err(|err| invalid_request(format!("invalid thread id: {err}")))
})
.transpose()?;
let Some(auth) = self.auth_manager.auth().await else {
return Err(invalid_request(
"codex account authentication required to read token usage",
@@ -1141,6 +1152,61 @@ impl AccountRequestProcessor {
&auth,
self.config.http_client_factory(),
);
if let Some(thread_id) = thread_id {
let thread_id = thread_id.to_string();
let usage = tokio::time::timeout(
THREAD_USAGE_FETCH_TIMEOUT,
client.get_thread_usage(&thread_id),
)
.await
.map_err(|_| internal_error("thread usage fetch timed out"))?;
let thread_usage = match usage {
Ok(usage) => Some(codex_app_server_protocol::ThreadUsage {
thread_id: usage.thread_id,
estimated_usage_credits_micros: usage.estimated_usage_credits_micros,
estimated_usage_usd_micros: usage.estimated_usage_usd_micros,
groups: usage
.groups
.into_iter()
.map(
|group| codex_app_server_protocol::ThreadUsageBreakdownGroup {
model: group.model,
reasoning_effort: group.reasoning_effort,
speed: group.speed,
estimated_usage_credits_micros: group
.estimated_usage_credits_micros,
net_new_input_tokens: group.net_new_input_tokens,
cached_input_tokens: group.cached_input_tokens,
input_tokens: group.input_tokens,
output_tokens: group.output_tokens,
total_tokens: group.total_tokens,
},
)
.collect(),
}),
Err(err)
if matches!(err.status().map(|status| status.as_u16()), Some(403 | 404)) =>
{
None
}
Err(err) => {
return Err(internal_error(format!(
"failed to fetch thread usage: {err}"
)));
}
};
return 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,
thread_usage,
});
}
let profile = tokio::time::timeout(
ACCOUNT_TOKEN_USAGE_FETCH_TIMEOUT,
client.get_token_usage_profile(),
@@ -1215,6 +1281,7 @@ impl AccountRequestProcessor {
})
.collect()
}),
thread_usage: None,
}
}
@@ -1364,6 +1431,7 @@ mod tests {
start_date: "2026-05-29".to_string(),
tokens: 10,
}]),
thread_usage: None,
}
);
}
@@ -0,0 +1,262 @@
use anyhow::Result;
use app_test_support::ChatGptAuthFixture;
use app_test_support::ChatGptIdTokenClaims;
use app_test_support::TestAppServer;
use app_test_support::encode_id_token;
use app_test_support::write_chatgpt_auth;
use codex_app_server_protocol::AccountTokenUsageSummary;
use codex_app_server_protocol::GetAccountTokenUsageResponse;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::LoginAccountResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadUsage;
use codex_app_server_protocol::ThreadUsageBreakdownGroup;
use codex_config::types::AuthCredentialsStoreMode;
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::TempDir;
use tokio::time::timeout;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::body_json;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(/*secs*/ 30);
#[tokio::test]
async fn account_thread_usage_uses_active_workspace_and_canonical_thread_ids() -> Result<()> {
let thread_id = "019fc8ab-1fb2-7000-8000-000000000123";
let codex_home = TempDir::new()?;
let server = MockServer::start().await;
std::fs::write(
codex_home.path().join("config.toml"),
format!("chatgpt_base_url = \"{}\"\n", server.uri()),
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("active-token").account_id("active-workspace"),
AuthCredentialsStoreMode::File,
)?;
let mut app_server = TestAppServer::builder()
.with_codex_home(codex_home.path())
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
.await?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("different-token").account_id("different-workspace"),
AuthCredentialsStoreMode::File,
)?;
Mock::given(method("POST"))
.and(path("/api/codex/usage/thread_usage/query"))
.and(header("authorization", "Bearer active-token"))
.and(header("chatgpt-account-id", "active-workspace"))
.and(body_json(json!({ "thread_ids": [thread_id] })))
.respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({
"threads": [{
"thread_id": thread_id,
"estimated_usage_credits_micros": 46_000_000,
"estimated_usage_usd_micros": null,
"groups": [{
"model": "gpt-5.4",
"reasoning_effort": "high",
"speed": "fast",
"estimated_usage_credits_micros": 46_000_000,
"net_new_input_tokens": 80,
"cached_input_tokens": 20,
"input_tokens": 100,
"output_tokens": 40,
"total_tokens": 140
}]
}]
})))
.expect(/*r*/ 1)
.mount(&server)
.await;
let request_id = app_server
.send_raw_request(
"account/usage/read",
Some(json!({ "threadId": "019FC8AB-1FB2-7000-8000-000000000123" })),
)
.await?;
let response: GetAccountTokenUsageResponse =
timeout(DEFAULT_READ_TIMEOUT, app_server.read_response(request_id)).await??;
assert_eq!(
response,
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,
thread_usage: Some(ThreadUsage {
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 46_000_000,
estimated_usage_usd_micros: None,
groups: vec![ThreadUsageBreakdownGroup {
model: Some("gpt-5.4".to_string()),
reasoning_effort: Some("high".to_string()),
speed: Some("fast".to_string()),
estimated_usage_credits_micros: 46_000_000,
net_new_input_tokens: Some(80),
cached_input_tokens: Some(20),
input_tokens: Some(100),
output_tokens: Some(40),
total_tokens: Some(140),
}],
}),
}
);
Ok(())
}
#[tokio::test]
async fn account_thread_usage_supports_externally_managed_authentication() -> Result<()> {
let thread_id = "019fc8ab-1fb2-7000-8000-000000000456";
let codex_home = TempDir::new()?;
let server = MockServer::start().await;
std::fs::write(
codex_home.path().join("config.toml"),
format!("chatgpt_base_url = \"{}\"\n", server.uri()),
)?;
let access_token = encode_id_token(
&ChatGptIdTokenClaims::new()
.email("external@example.com")
.plan_type("business")
.chatgpt_account_id("external-workspace"),
)?;
let mut app_server = TestAppServer::builder()
.with_codex_home(codex_home.path())
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
.await?;
let login_id = app_server
.send_chatgpt_auth_tokens_login_request(
access_token.clone(),
"external-workspace".to_string(),
Some("business".to_string()),
)
.await?;
let login: LoginAccountResponse =
timeout(DEFAULT_READ_TIMEOUT, app_server.read_response(login_id)).await??;
assert_eq!(login, LoginAccountResponse::ChatgptAuthTokens {});
Mock::given(method("POST"))
.and(path("/api/codex/usage/thread_usage/query"))
.and(header("authorization", format!("Bearer {access_token}")))
.and(header("chatgpt-account-id", "external-workspace"))
.and(body_json(json!({ "thread_ids": [thread_id] })))
.respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({
"threads": [{
"thread_id": thread_id,
"estimated_usage_credits_micros": 21_000_000,
"estimated_usage_usd_micros": 840_000
}]
})))
.expect(/*r*/ 1)
.mount(&server)
.await;
let request_id = app_server
.send_raw_request("account/usage/read", Some(json!({ "threadId": thread_id })))
.await?;
let response: GetAccountTokenUsageResponse =
timeout(DEFAULT_READ_TIMEOUT, app_server.read_response(request_id)).await??;
assert_eq!(
response.thread_usage,
Some(ThreadUsage {
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 21_000_000,
estimated_usage_usd_micros: Some(840_000),
groups: Vec::new(),
})
);
Ok(())
}
#[tokio::test]
async fn account_thread_usage_hides_unavailable_billing_routes() -> Result<()> {
let thread_id = "019fc8ab-1fb2-7000-8000-000000000789";
let codex_home = TempDir::new()?;
let server = MockServer::start().await;
std::fs::write(
codex_home.path().join("config.toml"),
format!("chatgpt_base_url = \"{}\"\n", server.uri()),
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("active-token").account_id("active-workspace"),
AuthCredentialsStoreMode::File,
)?;
Mock::given(method("POST"))
.and(path("/api/codex/usage/thread_usage/query"))
.respond_with(ResponseTemplate::new(/*s*/ 403))
.expect(/*r*/ 1)
.mount(&server)
.await;
let mut app_server = TestAppServer::builder()
.with_codex_home(codex_home.path())
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
.await?;
let request_id = app_server
.send_raw_request("account/usage/read", Some(json!({ "threadId": thread_id })))
.await?;
let response: GetAccountTokenUsageResponse =
timeout(DEFAULT_READ_TIMEOUT, app_server.read_response(request_id)).await??;
assert_eq!(response.thread_usage, None);
Ok(())
}
#[tokio::test]
async fn account_thread_usage_rejects_malformed_thread_ids_before_backend_requests() -> Result<()> {
let codex_home = TempDir::new()?;
let server = MockServer::start().await;
std::fs::write(
codex_home.path().join("config.toml"),
format!("chatgpt_base_url = \"{}\"\n", server.uri()),
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("active-token").account_id("active-workspace"),
AuthCredentialsStoreMode::File,
)?;
Mock::given(method("POST"))
.and(path("/api/codex/usage/thread_usage/query"))
.respond_with(ResponseTemplate::new(/*s*/ 200))
.expect(/*r*/ 0)
.mount(&server)
.await;
let mut app_server = TestAppServer::builder()
.with_codex_home(codex_home.path())
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
.await?;
let request_id = app_server
.send_raw_request(
"account/usage/read",
Some(json!({ "threadId": "not-a-thread-id" })),
)
.await?;
let error: JSONRPCError = timeout(
DEFAULT_READ_TIMEOUT,
app_server.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(error.error.code, -32600);
assert!(error.error.message.starts_with("invalid thread id:"));
Ok(())
}
@@ -1,4 +1,5 @@
mod account;
mod account_thread_usage;
mod analytics;
mod app_installed;
mod app_list;
+6
View File
@@ -530,6 +530,9 @@ pub(crate) struct App {
pub(crate) file_search: FileSearchManager,
pub(crate) transcript_cells: Vec<Arc<dyn HistoryCell>>,
last_rendered_history_tail: Option<history_ui::RenderedHistoryTail>,
last_thread_usage_status_cell: Option<history_ui::ThreadUsageStatusHistory>,
pub(crate) pending_thread_usage_history_refresh: bool,
// Pager overlay state (Transcript or Static like Diff)
pub(crate) overlay: Option<Overlay>,
@@ -1056,6 +1059,9 @@ See the Codex keymap documentation for supported actions and examples."
keymap: runtime_keymap,
key_chord_matcher: KeyChordMatcher::default(),
transcript_cells: Vec::new(),
last_rendered_history_tail: None,
last_thread_usage_status_cell: None,
pending_thread_usage_history_refresh: false,
overlay: None,
deferred_history_lines: Vec::new(),
has_emitted_history_lines: false,
@@ -111,6 +111,10 @@ impl App {
return;
}
ServerNotification::AccountUpdated(notification) => {
// Deferred terminal writes must never carry the previous account's billing into
// the newly authenticated identity, even when both accounts share one thread.
self.last_thread_usage_status_cell = None;
self.pending_thread_usage_history_refresh = false;
let has_codex_backend_auth = matches!(
notification.auth_mode,
Some(
+117 -1
View File
@@ -8,18 +8,20 @@ use super::plugin_mentions::fetch_plugin_mentions;
use super::*;
use crate::app_event::ConnectorsSnapshot;
use crate::app_info::app_info_from_api;
use crate::chatwidget::ThreadUsageOutcome;
use crate::config_update::format_config_error;
use codex_app_server_protocol::AppsListParams;
use codex_app_server_protocol::AppsListResponse;
use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditParams;
use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditResponse;
use codex_app_server_protocol::GetAccountTokenUsageParams;
use codex_app_server_protocol::GetAccountTokenUsageResponse;
use codex_app_server_protocol::MarketplaceAddParams;
use codex_app_server_protocol::MarketplaceAddResponse;
use codex_app_server_protocol::MarketplaceRemoveParams;
use codex_app_server_protocol::MarketplaceRemoveResponse;
use codex_app_server_protocol::MarketplaceUpgradeParams;
use codex_app_server_protocol::MarketplaceUpgradeResponse;
use codex_app_server_protocol::RequestId;
use crate::hooks_rpc::fetch_hooks_list;
@@ -29,6 +31,8 @@ use codex_utils_absolute_path::AbsolutePathBuf;
const TOKEN_ACTIVITY_FETCH_TIMEOUT: std::time::Duration =
std::time::Duration::from_secs(/*secs*/ 15);
const THREAD_USAGE_FETCH_TIMEOUT: std::time::Duration =
std::time::Duration::from_secs(/*secs*/ 65);
const RATE_LIMIT_RESET_REQUEST_TIMEOUT: std::time::Duration =
std::time::Duration::from_secs(/*secs*/ 15);
const WORKSPACE_HEADLINE_FETCH_TIMEOUT: std::time::Duration =
@@ -124,6 +128,30 @@ impl App {
});
}
pub(super) fn refresh_thread_usage(
&mut self,
app_server: &AppServerSession,
thread_id: ThreadId,
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(
THREAD_USAGE_FETCH_TIMEOUT,
fetch_thread_usage(request_handle, thread_id),
)
.await
.map_err(|_| "thread usage request timed out in TUI".to_string())
.and_then(|result| result.map_err(|err| err.to_string()));
app_event_tx.send(AppEvent::ThreadUsageLoaded {
thread_id,
request_id,
result,
});
});
}
pub(super) fn consume_rate_limit_reset_credit(
&mut self,
app_server: &AppServerSession,
@@ -783,6 +811,26 @@ pub(super) async fn fetch_account_token_activity(
.wrap_err("account/usage/read failed in TUI")
}
async fn fetch_thread_usage(
request_handle: AppServerRequestHandle,
thread_id: ThreadId,
) -> Result<ThreadUsageOutcome> {
let request_id = RequestId::String(format!("thread-usage-{}", Uuid::new_v4()));
let response: GetAccountTokenUsageResponse = request_handle
.request_typed(ClientRequest::GetAccountTokenUsage {
request_id,
params: Some(GetAccountTokenUsageParams {
thread_id: Some(thread_id.to_string()),
}),
})
.await
.wrap_err("account/usage/read failed for thread usage in TUI")?;
Ok(response
.thread_usage
.map(ThreadUsageOutcome::Available)
.unwrap_or(ThreadUsageOutcome::Disabled))
}
pub(super) async fn consume_rate_limit_reset_credit_request(
request_handle: AppServerRequestHandle,
idempotency_key: String,
@@ -1285,11 +1333,79 @@ pub(super) fn mcp_inventory_maps_from_statuses(statuses: Vec<McpServerStatus>) -
mod tests {
use super::*;
use crate::app::test_support::make_test_app;
use app_test_support::ChatGptAuthFixture;
use app_test_support::write_chatgpt_auth;
use codex_app_server_protocol::PluginMarketplaceEntry;
use codex_app_server_protocol::ThreadUsage;
use codex_config::types::AuthCredentialsStoreMode;
use codex_protocol::mcp::Tool;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn fetch_thread_usage_uses_app_server_auth_after_persisted_account_changes() {
let mut app = make_test_app().await;
let server = wiremock::MockServer::start().await;
let thread_id = ThreadId::new();
app.config.chatgpt_base_url = server.uri();
app.config.cli_auth_credentials_store_mode = AuthCredentialsStoreMode::File;
write_chatgpt_auth(
app.config.codex_home.as_path(),
ChatGptAuthFixture::new("chatgpt-token").account_id("account-123"),
AuthCredentialsStoreMode::File,
)
.expect("write ChatGPT authentication");
let app_server = crate::start_embedded_app_server_for_picker(&app.config)
.await
.expect("start authenticated embedded app server");
write_chatgpt_auth(
app.config.codex_home.as_path(),
ChatGptAuthFixture::new("different-token").account_id("different-account"),
AuthCredentialsStoreMode::File,
)
.expect("replace persisted ChatGPT authentication");
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/api/codex/usage/thread_usage/query",
))
.and(wiremock::matchers::header(
"authorization",
"Bearer chatgpt-token",
))
.and(wiremock::matchers::header(
"chatgpt-account-id",
"account-123",
))
.and(wiremock::matchers::body_json(serde_json::json!({
"thread_ids": [thread_id.to_string()]
})))
.respond_with(wiremock::ResponseTemplate::new(/*s*/ 200).set_body_json(
serde_json::json!({
"threads": [{
"thread_id": thread_id.to_string(),
"estimated_usage_credits_micros": 46_000_000,
"estimated_usage_usd_micros": 1_820_000
}]
}),
))
.expect(/*r*/ 1)
.mount(&server)
.await;
assert_eq!(
fetch_thread_usage(app_server.request_handle(), thread_id)
.await
.expect("read thread usage through the authenticated app server"),
ThreadUsageOutcome::Available(ThreadUsage {
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 46_000_000,
estimated_usage_usd_micros: Some(1_820_000),
groups: Vec::new(),
})
);
}
fn test_absolute_path(path: &str) -> AbsolutePathBuf {
AbsolutePathBuf::try_from(PathBuf::from(path)).expect("absolute test path")
}
+13
View File
@@ -983,6 +983,12 @@ impl App {
AppEvent::RefreshTokenActivity { request_id } => {
self.refresh_token_activity(app_server, request_id);
}
AppEvent::RefreshThreadUsage {
thread_id,
request_id,
} => {
self.refresh_thread_usage(app_server, thread_id, request_id);
}
AppEvent::RefreshStatusLineWorkspaceHeadline { request_id } => {
self.refresh_status_line_workspace_headline(app_server, request_id);
}
@@ -1208,6 +1214,13 @@ impl App {
self.insert_pending_usage_output_if_ready(tui);
}
}
AppEvent::ThreadUsageLoaded {
thread_id,
request_id,
result,
} => {
self.finish_thread_usage_refresh(tui, thread_id, request_id, result)?;
}
AppEvent::CommitPendingUsageOutput => {
self.insert_pending_usage_output_if_ready(tui);
}
+157 -12
View File
@@ -4,9 +4,22 @@
//! state, and resetting transcript-related app state after `/clear` or Ctrl-L.
use super::*;
use crate::terminal_hyperlinks::HyperlinkLine;
use std::sync::Weak;
const DESKTOP_THREAD_OPENED_MESSAGE: &str = "Opened this session in the Desktop app.";
pub(super) struct RenderedHistoryTail {
pub(super) cell: Weak<dyn HistoryCell>,
pub(super) lines: Vec<HyperlinkLine>,
}
pub(super) struct ThreadUsageStatusHistory {
thread_id: ThreadId,
pub(super) cell: Weak<dyn HistoryCell>,
pub(super) lines: Vec<HyperlinkLine>,
}
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();
@@ -15,26 +28,155 @@ impl App {
tui.frame_requester().schedule_frame();
}
self.transcript_cells.push(cell.clone());
let width = self
.chat_widget
.history_wrap_width(tui.terminal.last_known_screen_size.width);
let lines =
cell.display_hyperlink_lines_for_mode(width, self.chat_widget.history_render_mode());
if cell.as_any().is::<history_cell::CompositeHistoryCell>()
&& lines.first().is_some_and(|line| {
line.line.spans.len() == 1 && line.line.spans[0].content.as_ref() == "/status"
})
&& let Some(thread_id) = self.chat_widget.thread_id()
{
self.last_thread_usage_status_cell = Some(ThreadUsageStatusHistory {
thread_id,
cell: Arc::downgrade(&cell),
lines: lines.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),
);
self.insert_history_cell_lines_with_initial_replay_buffer(tui, cell.as_ref(), width);
self.last_rendered_history_tail = None;
} 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_lines(tui, cell.as_ref(), width);
self.last_rendered_history_tail = if self.overlay.is_none() && !lines.is_empty() {
Some(RenderedHistoryTail {
cell: Arc::downgrade(&cell),
lines,
})
} else {
None
};
}
// 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_pending_usage_output_insertion();
}
pub(super) fn finish_thread_usage_refresh(
&mut self,
tui: &mut tui::Tui,
thread_id: ThreadId,
request_id: u64,
result: std::result::Result<crate::chatwidget::ThreadUsageOutcome, String>,
) -> Result<()> {
let may_update_status = match &result {
Ok(crate::chatwidget::ThreadUsageOutcome::Available(usage)) => {
usage.thread_id == thread_id.to_string()
}
Ok(crate::chatwidget::ThreadUsageOutcome::Disabled) => true,
Err(_) => false,
};
if !self
.chat_widget
.finish_thread_usage_refresh(thread_id, request_id, result)
|| !may_update_status
{
return Ok(());
}
if self.overlay.is_some() || self.initial_history_replay_buffer.is_some() {
self.pending_thread_usage_history_refresh = true;
return Ok(());
}
self.refresh_thread_usage_history_tail(tui)
}
pub(crate) fn refresh_thread_usage_history_tail(&mut self, tui: &mut tui::Tui) -> Result<()> {
let Some(status_history) = self.last_thread_usage_status_cell.as_ref() else {
self.pending_thread_usage_history_refresh = false;
return Ok(());
};
if self.chat_widget.thread_id() != Some(status_history.thread_id) {
self.last_thread_usage_status_cell = None;
self.pending_thread_usage_history_refresh = false;
return Ok(());
}
let Some(status_cell) = status_history.cell.upgrade() else {
self.last_thread_usage_status_cell = None;
self.pending_thread_usage_history_refresh = false;
return Ok(());
};
let width = self
.chat_widget
.history_wrap_width(tui.terminal.last_known_screen_size.width);
let updated_lines = status_cell
.display_hyperlink_lines_for_mode(width, self.chat_widget.history_render_mode());
if updated_lines == status_history.lines {
self.pending_thread_usage_history_refresh = false;
return Ok(());
}
let Some(rendered_tail) = self.last_rendered_history_tail.as_ref() else {
self.insert_history_cell_lines(tui, status_cell.as_ref(), width);
self.last_rendered_history_tail = Some(RenderedHistoryTail {
cell: Arc::downgrade(&status_cell),
lines: updated_lines.clone(),
});
if let Some(status_history) = self.last_thread_usage_status_cell.as_mut() {
status_history.lines = updated_lines;
}
self.pending_thread_usage_history_refresh = false;
return Ok(());
};
if !rendered_tail
.cell
.upgrade()
.is_some_and(|cell| Arc::ptr_eq(&cell, &status_cell))
{
self.insert_history_cell_lines(tui, status_cell.as_ref(), width);
self.last_rendered_history_tail = Some(RenderedHistoryTail {
cell: Arc::downgrade(&status_cell),
lines: updated_lines.clone(),
});
if let Some(status_history) = self.last_thread_usage_status_cell.as_mut() {
status_history.lines = updated_lines;
}
self.pending_thread_usage_history_refresh = false;
return Ok(());
}
let prefix_len = rendered_tail
.lines
.iter()
.zip(&updated_lines)
.take_while(|(previous, updated)| previous == updated)
.count();
if prefix_len == rendered_tail.lines.len() && prefix_len == updated_lines.len() {
self.pending_thread_usage_history_refresh = false;
return Ok(());
}
let wrap_policy = self.history_line_wrap_policy();
if !tui.replace_visible_history_tail(
&rendered_tail.lines[prefix_len..],
&updated_lines[prefix_len..],
wrap_policy,
)? {
self.insert_history_cell_lines(tui, status_cell.as_ref(), width);
}
self.last_rendered_history_tail = Some(RenderedHistoryTail {
cell: Arc::downgrade(&status_cell),
lines: updated_lines.clone(),
});
if let Some(status_history) = self.last_thread_usage_status_cell.as_mut() {
status_history.lines = updated_lines;
}
self.pending_thread_usage_history_refresh = false;
Ok(())
}
pub(super) fn pending_usage_output_insertion_blocked(&self) -> bool {
self.chat_widget.usage_history_insertion_blocked()
|| self
@@ -165,6 +307,9 @@ impl App {
pub(super) fn reset_transcript_state_after_clear(&mut self) {
self.overlay = None;
self.transcript_cells.clear();
self.last_rendered_history_tail = None;
self.last_thread_usage_status_cell = None;
self.pending_thread_usage_history_refresh = false;
self.deferred_history_lines.clear();
self.has_emitted_history_lines = false;
self.transcript_reflow.clear();
+25
View File
@@ -81,6 +81,7 @@ impl App {
pub(super) fn reset_history_emission_state(&mut self) {
self.has_emitted_history_lines = false;
self.deferred_history_lines.clear();
self.last_rendered_history_tail = None;
}
fn display_lines_for_history_insert(
@@ -179,6 +180,11 @@ impl App {
retained_lines,
self.history_line_wrap_policy(),
);
if self.pending_thread_usage_history_refresh
&& let Err(err) = self.refresh_thread_usage_history_tail(tui)
{
tracing::warn!(error = %err, "failed to refresh thread usage after initial replay");
}
self.request_scrollback_history_top_up(retained_rows);
}
@@ -480,6 +486,25 @@ impl App {
self.history_line_wrap_policy(),
);
}
self.last_rendered_history_tail =
self.transcript_cells
.last()
.map(|cell| super::history_ui::RenderedHistoryTail {
cell: Arc::downgrade(cell),
lines: cell.display_hyperlink_lines_for_mode(
width,
self.chat_widget.history_render_mode(),
),
});
if let Some(status_history) = self.last_thread_usage_status_cell.as_mut()
&& let Some(cell) = status_history.cell.upgrade()
{
status_history.lines = cell
.display_hyperlink_lines_for_mode(width, self.chat_widget.history_render_mode());
}
if self.pending_thread_usage_history_refresh {
self.refresh_thread_usage_history_tail(tui)?;
}
self.request_scrollback_history_top_up(reflowed_rows);
Ok(terminal_width)
+3
View File
@@ -33,6 +33,9 @@ pub(super) async fn make_test_app() -> App {
runtime_permission_profile_override: None,
file_search,
transcript_cells: Vec::new(),
last_rendered_history_tail: None,
last_thread_usage_status_cell: None,
pending_thread_usage_history_refresh: false,
overlay: None,
deferred_history_lines: Vec::new(),
has_emitted_history_lines: false,
+8
View File
@@ -14,6 +14,8 @@ mod safety_buffering;
mod session_lifecycle_requests;
mod session_summary;
mod startup;
#[path = "tests/thread_usage.rs"]
mod thread_usage;
#[path = "tests/turn_submission.rs"]
mod turn_submission;
@@ -4801,6 +4803,9 @@ async fn make_test_app() -> App {
runtime_permission_profile_override: None,
file_search,
transcript_cells: Vec::new(),
last_rendered_history_tail: None,
last_thread_usage_status_cell: None,
pending_thread_usage_history_refresh: false,
overlay: None,
deferred_history_lines: Vec::new(),
has_emitted_history_lines: false,
@@ -4871,6 +4876,9 @@ async fn make_test_app_with_channels() -> (
runtime_permission_profile_override: None,
file_search,
transcript_cells: Vec::new(),
last_rendered_history_tail: None,
last_thread_usage_status_cell: None,
pending_thread_usage_history_refresh: false,
overlay: None,
deferred_history_lines: Vec::new(),
has_emitted_history_lines: false,
+312
View File
@@ -0,0 +1,312 @@
use super::*;
use crate::chatwidget::ThreadUsageOutcome;
use codex_app_server_client::AppServerEvent;
use codex_app_server_protocol::AccountUpdatedNotification;
use codex_app_server_protocol::AuthMode;
use codex_app_server_protocol::ThreadUsage;
use codex_protocol::account::PlanType;
use pretty_assertions::assert_eq;
use ratatui::layout::Rect;
use ratatui::layout::Size;
async fn app_with_pending_thread_usage() -> Result<(App, AppServerSession, tui::Tui, ThreadId, u64)>
{
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
app.chat_widget.handle_thread_session(test_thread_session(
thread_id,
test_path_buf("/tmp/project"),
));
app.chat_widget.update_account_state(
/*status_account_display*/ None,
Some(PlanType::Business),
/*has_chatgpt_account*/ false,
/*has_codex_backend_auth*/ true,
);
while app_event_rx.try_recv().is_ok() {}
let mut app_server =
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
let mut tui = crate::tui::test_support::make_test_tui()?;
tui.terminal.set_viewport_area(Rect::new(
/*x*/ 0, /*y*/ 21, /*width*/ 80, /*height*/ 3,
));
app.chat_widget.add_status_output(
/*refreshing_rate_limits*/ false, /*request_id*/ None,
);
let initial_status = match app_event_rx.try_recv() {
Ok(event @ AppEvent::InsertHistoryCell(_)) => event,
other => panic!("expected immediate status card, got {other:?}"),
};
let request_id = match app_event_rx.try_recv() {
Ok(AppEvent::RefreshThreadUsage {
thread_id: requested_thread_id,
request_id,
}) if requested_thread_id == thread_id => request_id,
other => panic!("expected asynchronous thread usage request, got {other:?}"),
};
app.handle_event(&mut tui, &mut app_server, initial_status)
.await?;
Ok((app, app_server, tui, thread_id, request_id))
}
fn successful_thread_usage(thread_id: ThreadId, request_id: u64) -> AppEvent {
AppEvent::ThreadUsageLoaded {
thread_id,
request_id,
result: Ok(ThreadUsageOutcome::Available(ThreadUsage {
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 50_000_000,
estimated_usage_usd_micros: Some(1_820_000),
groups: Vec::new(),
})),
}
}
fn pending_history_text(tui: &tui::Tui) -> String {
tui.pending_history_lines_for_test()
.into_iter()
.map(|line| {
line.line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
#[tokio::test]
async fn account_updated_with_backend_only_auth_enables_thread_usage() -> Result<()> {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
app.chat_widget.handle_thread_session(test_thread_session(
thread_id,
test_path_buf("/tmp/project"),
));
while app_event_rx.try_recv().is_ok() {}
let app_server =
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
app.handle_app_server_event(
&app_server,
AppServerEvent::ServerNotification(Box::new(ServerNotification::AccountUpdated(
AccountUpdatedNotification {
auth_mode: Some(AuthMode::AgentIdentity),
plan_type: Some(PlanType::Business),
},
))),
)
.await;
assert!(!app.chat_widget.has_chatgpt_account());
assert!(app.chat_widget.has_codex_backend_auth());
app.chat_widget.add_status_output(
/*refreshing_rate_limits*/ false, /*request_id*/ None,
);
assert_matches!(app_event_rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_)));
assert_matches!(
app_event_rx.try_recv(),
Ok(AppEvent::RefreshThreadUsage {
thread_id: requested_thread_id,
..
}) if requested_thread_id == thread_id
);
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn completed_thread_usage_updates_status_without_scrollback_reflow() -> Result<()> {
let (mut app, mut app_server, mut tui, thread_id, request_id) =
app_with_pending_thread_usage().await?;
assert!(!app.transcript_reflow.has_pending_reflow());
assert!(pending_history_text(&tui).contains("/status"));
app.handle_event(
&mut tui,
&mut app_server,
successful_thread_usage(thread_id, request_id),
)
.await?;
assert!(!app.transcript_reflow.has_pending_reflow());
assert!(
tui.pending_history_lines_for_test().is_empty(),
"a visible status tail should be replaced directly instead of appending a duplicate card"
);
let status = app
.transcript_cells
.last()
.expect("status card should remain in terminal history");
let rendered = lines_to_single_string(&status.display_lines(/*width*/ 90));
assert!(rendered.contains("50 credits"), "{rendered}");
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn thread_usage_after_intervening_history_appends_refreshed_status() -> Result<()> {
let (mut app, mut app_server, mut tui, thread_id, request_id) =
app_with_pending_thread_usage().await?;
app.insert_history_cell(
&mut tui,
Box::new(PlainHistoryCell::new(vec![
Line::from("/status"),
Line::from("intervening model output"),
])),
);
app.handle_event(
&mut tui,
&mut app_server,
successful_thread_usage(thread_id, request_id),
)
.await?;
let pending = pending_history_text(&tui);
assert!(pending.contains("intervening model output"), "{pending}");
assert!(pending.contains("50 credits"), "{pending}");
assert!(!app.transcript_reflow.has_pending_reflow());
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn failed_thread_usage_after_intervening_history_does_not_duplicate_status() -> Result<()> {
let (mut app, mut app_server, mut tui, thread_id, request_id) =
app_with_pending_thread_usage().await?;
app.insert_history_cell(
&mut tui,
Box::new(PlainHistoryCell::new(vec![Line::from(
"intervening model output",
)])),
);
let pending_before_failure = pending_history_text(&tui);
app.handle_event(
&mut tui,
&mut app_server,
AppEvent::ThreadUsageLoaded {
thread_id,
request_id,
result: Err("thread usage request timed out".to_string()),
},
)
.await?;
assert_eq!(pending_history_text(&tui), pending_before_failure);
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn thread_usage_finishing_during_overlay_updates_history_after_close() -> Result<()> {
let (mut app, mut app_server, mut tui, thread_id, request_id) =
app_with_pending_thread_usage().await?;
tui.set_alt_screen_enabled(/*enabled*/ false);
app.open_transcript_overlay(&mut tui);
app.handle_event(
&mut tui,
&mut app_server,
successful_thread_usage(thread_id, request_id),
)
.await?;
assert!(app.pending_thread_usage_history_refresh);
app.close_transcript_overlay(&mut tui);
assert!(!app.pending_thread_usage_history_refresh);
assert!(tui.pending_history_lines_for_test().is_empty());
assert!(
app.last_rendered_history_tail
.as_ref()
.expect("updated status should remain the rendered history tail")
.lines
.iter()
.any(|line| line.line.to_string().contains("50 credits"))
);
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn account_change_discards_thread_usage_deferred_while_overlay_is_open() -> Result<()> {
let (mut app, mut app_server, mut tui, thread_id, request_id) =
app_with_pending_thread_usage().await?;
tui.set_alt_screen_enabled(/*enabled*/ false);
app.open_transcript_overlay(&mut tui);
app.handle_event(
&mut tui,
&mut app_server,
successful_thread_usage(thread_id, request_id),
)
.await?;
assert!(app.pending_thread_usage_history_refresh);
app.handle_app_server_event(
&app_server,
AppServerEvent::ServerNotification(Box::new(ServerNotification::AccountUpdated(
AccountUpdatedNotification {
auth_mode: Some(AuthMode::AgentIdentity),
plan_type: Some(PlanType::Business),
},
))),
)
.await;
assert!(!app.pending_thread_usage_history_refresh);
assert!(app.last_thread_usage_status_cell.is_none());
app.close_transcript_overlay(&mut tui);
assert!(!pending_history_text(&tui).contains("50 credits"));
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn terminal_reflow_rebases_pending_status_update_to_new_width() -> Result<()> {
let (mut app, mut app_server, mut tui, thread_id, request_id) =
app_with_pending_thread_usage().await?;
let original = app
.last_rendered_history_tail
.as_ref()
.expect("initial rendered status")
.lines
.clone();
let screen_size = Size::new(/*width*/ 52, /*height*/ 24);
tui.terminal.last_known_screen_size = screen_size;
let viewport = Rect::new(
/*x*/ 0,
/*y*/ 21,
screen_size.width,
/*height*/ 3,
);
tui.terminal.set_viewport_area(viewport);
app.reflow_transcript_now(&mut tui, screen_size.into())?;
let resized = app
.last_rendered_history_tail
.as_ref()
.expect("resized rendered status")
.lines
.clone();
assert_ne!(original, resized);
// Bazel tests run without a TTY, so model the settled viewport explicitly
// before simulating an asynchronous usage update after resize replay.
tui.terminal.set_viewport_area(viewport);
app.handle_event(
&mut tui,
&mut app_server,
successful_thread_usage(thread_id, request_id),
)
.await?;
assert!(tui.pending_history_lines_for_test().is_empty());
assert!(!app.transcript_reflow.has_pending_reflow());
app_server.shutdown().await?;
Ok(())
}
+5
View File
@@ -241,6 +241,11 @@ impl App {
);
}
self.overlay = None;
if self.pending_thread_usage_history_refresh
&& let Err(err) = self.refresh_thread_usage_history_tail(tui)
{
tracing::warn!(error = %err, "failed to refresh thread usage after closing overlay");
}
self.backtrack.overlay_preview_active = false;
tui.frame_requester().schedule_frame();
if was_backtrack {
+14
View File
@@ -47,6 +47,7 @@ use crate::app_server_session::AppServerStartedThread;
use crate::bottom_pane::ApprovalRequest;
use crate::bottom_pane::StatusLineItem;
use crate::bottom_pane::TerminalTitleItem;
use crate::chatwidget::ThreadUsageOutcome;
use crate::chatwidget::UserMessage;
use crate::goal_files::GoalDraft;
use codex_app_server_protocol::AskForApproval;
@@ -452,6 +453,19 @@ pub(crate) enum AppEvent {
result: Result<GetAccountTokenUsageResponse, String>,
},
/// Fetch backend-estimated usage for the currently visible enterprise thread.
RefreshThreadUsage {
thread_id: ThreadId,
request_id: u64,
},
/// Result of fetching backend-estimated usage for a specific thread.
ThreadUsageLoaded {
thread_id: ThreadId,
request_id: u64,
result: Result<ThreadUsageOutcome, String>,
},
/// Fetch workspace messages for the status-line headline item.
RefreshStatusLineWorkspaceHeadline {
request_id: u64,
+4
View File
@@ -423,6 +423,8 @@ mod status_controls;
mod status_surfaces;
mod streaming;
use self::status_surfaces::CachedProjectRootName;
mod thread_usage;
pub(crate) use self::thread_usage::ThreadUsageOutcome;
mod tokens;
pub(crate) use self::tokens::TokenActivityView;
mod tool_lifecycle;
@@ -764,6 +766,8 @@ pub(crate) struct ChatWidget {
status_line_workspace_headline_last_requested_at: Option<Instant>,
// Set after the backend reports the workspace-message feature gate is disabled.
status_line_workspace_messages_disabled: bool,
// Cached backend-estimated cost and bounded refresh state for the current thread.
thread_usage: thread_usage::ThreadUsageState,
// Current thread-goal status shown in the status line when plan mode is inactive.
current_goal_status_indicator: Option<GoalStatusIndicator>,
current_goal_status: Option<GoalStatusState>,
@@ -246,6 +246,7 @@ impl ChatWidget {
next_status_line_workspace_headline_request_id: 0,
status_line_workspace_headline_last_requested_at: None,
status_line_workspace_messages_disabled: false,
thread_usage: thread_usage::ThreadUsageState::default(),
current_goal_status_indicator: None,
current_goal_status: None,
external_editor_state: ExternalEditorState::Closed,
@@ -24,6 +24,7 @@ impl ChatWidget {
.set_queue_submissions(/*queue_submissions*/ false);
if previous_thread_id != self.thread_id {
self.review.recent_auto_review_denials = RecentAutoReviewDenials::default();
self.clear_thread_usage_state();
}
self.refresh_plan_mode_nudge();
self.turn_lifecycle.reset_thread();
+1
View File
@@ -236,6 +236,7 @@ impl ChatWidget {
self.status_line_workspace_headline_pending_request_id = None;
self.status_line_workspace_headline_last_requested_at = None;
self.status_line_workspace_messages_disabled = false;
self.clear_thread_usage_state();
self.status_account_display = status_account_display;
self.plan_type = plan_type;
self.has_chatgpt_account = has_chatgpt_account;
@@ -0,0 +1,10 @@
---
source: tui/src/chatwidget/tests/status_command_tests.rs
expression: thread_usage
---
│ Thread usage: 50 credits · ~$1.82 │
│ Models: GPT-5.4 80%, GPT-5 Mini 20% │
│ Reasoning: Medium 20%, High 80% │
│ Speed: Fast mode 80%, Standard 20% │
│ Billed tokens: 120 input (25 cached) + 50 output │
╰──────────────────────────────────────────────────────────────────────╯
@@ -0,0 +1,10 @@
---
source: tui/src/chatwidget/tests/status_command_tests.rs
expression: thread_usage
---
│ Thread usage: 50 credits │
│ Models: GPT-5.4 100% │
│ Reasoning: High 100% │
│ Speed: Fast mode 100% │
│ Billed tokens: 100 input (20 cached) + 40 output │
╰──────────────────────────────────────────────────────────────────────╯
+10 -2
View File
@@ -245,9 +245,17 @@ impl ChatWidget {
refreshing_rate_limits,
);
if let Some(request_id) = request_id {
self.refreshing_status_outputs.push((request_id, handle));
self.refreshing_status_outputs
.push((request_id, handle.clone()));
}
if self.thread_usage_is_available() {
handle.reserve_thread_usage_label_width();
handle.set_thread_usage(self.estimated_thread_usage().cloned());
self.add_to_history(cell);
self.request_thread_usage_for_status(handle);
} else {
self.add_to_history(cell);
}
self.add_to_history(cell);
}
pub(crate) fn finish_status_rate_limit_refresh(
@@ -1,5 +1,8 @@
use super::*;
use crate::chatwidget::ThreadUsageOutcome;
use assert_matches::assert_matches;
use codex_app_server_protocol::ThreadUsage;
use codex_app_server_protocol::ThreadUsageBreakdownGroup;
use codex_utils_path_uri::PathUri;
#[tokio::test]
@@ -197,3 +200,417 @@ async fn account_update_rejects_stale_status_rate_limit_snapshots() {
assert!(chat.rate_limit_snapshots_by_limit_id.is_empty());
}
#[tokio::test]
async fn status_command_renders_immediately_and_updates_first_card_without_status_line() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
let thread_id =
ThreadId::from_string("019fc8ab-1fb2-7000-8000-000000000001").expect("valid thread id");
chat.thread_id = Some(thread_id);
chat.plan_type = Some(PlanType::Business);
chat.dispatch_command(SlashCommand::Status);
let first_cell = match rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
event => panic!("expected immediate /status output, got {event:?}"),
};
let initial = lines_to_single_string(&first_cell.display_lines(/*width*/ 90));
assert!(!initial.contains("Thread usage"));
let request_id = match rx.try_recv() {
Ok(AppEvent::RefreshThreadUsage {
thread_id: requested_thread_id,
request_id,
}) => {
assert_eq!(requested_thread_id, thread_id);
request_id
}
event => panic!("expected /status to request thread usage, got {event:?}"),
};
drain_insert_history(&mut rx);
assert!(chat.finish_thread_usage_refresh(
thread_id,
request_id,
Ok(ThreadUsageOutcome::Available(ThreadUsage {
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 50_000_000,
estimated_usage_usd_micros: Some(1_820_000),
groups: vec![
ThreadUsageBreakdownGroup {
model: Some("gpt-5.4".to_string()),
reasoning_effort: Some("high".to_string()),
speed: Some("fast".to_string()),
estimated_usage_credits_micros: 40_000_000,
net_new_input_tokens: Some(80),
cached_input_tokens: Some(20),
input_tokens: Some(100),
output_tokens: Some(40),
total_tokens: Some(140),
},
ThreadUsageBreakdownGroup {
model: Some("gpt-5-mini".to_string()),
reasoning_effort: Some("medium".to_string()),
speed: Some("standard".to_string()),
estimated_usage_credits_micros: 10_000_000,
net_new_input_tokens: Some(15),
cached_input_tokens: Some(5),
input_tokens: Some(20),
output_tokens: Some(10),
total_tokens: Some(30),
},
],
})),
));
let settled = lines_to_single_string(&first_cell.display_lines(/*width*/ 90));
assert!(settled.contains("50 credits · ~$1.82"));
assert!(settled.contains("GPT-5.4 80%, GPT-5 Mini 20%"));
assert!(drain_insert_history(&mut rx).is_empty());
chat.dispatch_command(SlashCommand::Status);
let cell = match rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
event => panic!("expected /status output with cached thread usage, got {event:?}"),
};
let rendered =
normalize_snapshot_paths(lines_to_single_string(&cell.display_lines(/*width*/ 90)));
assert!(rendered.contains("50 credits · ~$1.82"));
assert!(rendered.contains("GPT-5.4 80%, GPT-5 Mini 20%"));
assert!(rendered.contains("Medium 20%, High 80%"));
assert!(rendered.contains("Fast mode 80%, Standard 20%"));
assert!(rendered.contains("120 input (25 cached) + 50 output"));
assert!(
rendered.find("Limits:") < rendered.find("Thread usage:"),
"account limits should appear above the thread usage group: {rendered}"
);
let thread_usage = rendered
.lines()
.skip_while(|line| !line.contains("Thread usage:"))
.collect::<Vec<_>>()
.join("\n");
assert_chatwidget_snapshot!("status_command_estimated_thread_usage", thread_usage);
}
#[tokio::test]
async fn status_command_omits_thread_usage_for_non_cbp_enterprise_plans() {
for plan_type in [
Some(PlanType::Enterprise),
Some(PlanType::Ent26),
Some(PlanType::Pro),
Some(PlanType::Edu),
Some(PlanType::SelfServeBusinessUsageBased),
Some(PlanType::Unknown),
None,
] {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
chat.thread_id = Some(ThreadId::new());
chat.plan_type = plan_type;
chat.dispatch_command(SlashCommand::Status);
let rendered = match rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => {
lines_to_single_string(&cell.display_lines(/*width*/ 90))
}
event => panic!("expected immediate /status output, got {event:?}"),
};
assert!(!rendered.contains("Thread usage"));
assert!(
!std::iter::from_fn(|| rx.try_recv().ok())
.any(|event| matches!(event, AppEvent::RefreshThreadUsage { .. })),
"unsupported plan {plan_type:?} must not request thread usage",
);
}
}
#[tokio::test]
async fn status_command_stays_visible_and_updates_after_thread_usage_retry() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
chat.plan_type = Some(PlanType::Business);
chat.dispatch_command(SlashCommand::Status);
let cell = match rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
event => panic!("expected immediate /status output, got {event:?}"),
};
assert!(!lines_to_single_string(&cell.display_lines(/*width*/ 90)).contains("Thread usage"));
let request_id = match rx.try_recv() {
Ok(AppEvent::RefreshThreadUsage { request_id, .. }) => request_id,
event => panic!("expected thread usage request, got {event:?}"),
};
while rx.try_recv().is_ok() {}
assert!(chat.finish_thread_usage_refresh(
thread_id,
request_id,
Err("thread usage temporarily unavailable".to_string()),
));
assert!(drain_insert_history(&mut rx).is_empty());
chat.dispatch_command(SlashCommand::Status);
let retry_cell = match rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
event => panic!("expected another immediate /status output, got {event:?}"),
};
let retry_request_id = match rx.try_recv() {
Ok(AppEvent::RefreshThreadUsage { request_id, .. }) => request_id,
event => panic!("expected thread usage retry, got {event:?}"),
};
assert!(chat.finish_thread_usage_refresh(
thread_id,
retry_request_id,
Ok(ThreadUsageOutcome::Available(ThreadUsage {
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 50_000_000,
estimated_usage_usd_micros: Some(1_820_000),
groups: Vec::new(),
})),
));
for status_cell in [&cell, &retry_cell] {
assert!(
lines_to_single_string(&status_cell.display_lines(/*width*/ 90))
.contains("50 credits · ~$1.82")
);
}
}
#[tokio::test]
async fn status_command_renders_credits_and_breakdowns_without_usd_estimate() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
chat.plan_type = Some(PlanType::Business);
chat.dispatch_command(SlashCommand::Status);
let first_cell = match rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
event => panic!("expected immediate /status output, got {event:?}"),
};
let request_id = match rx.try_recv() {
Ok(AppEvent::RefreshThreadUsage { request_id, .. }) => request_id,
event => panic!("expected thread usage request, got {event:?}"),
};
drain_insert_history(&mut rx);
assert!(chat.finish_thread_usage_refresh(
thread_id,
request_id,
Ok(ThreadUsageOutcome::Available(ThreadUsage {
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 50_000_000,
estimated_usage_usd_micros: None,
groups: vec![ThreadUsageBreakdownGroup {
model: Some("gpt-5.4".to_string()),
reasoning_effort: Some("high".to_string()),
speed: Some("fast".to_string()),
estimated_usage_credits_micros: 50_000_000,
net_new_input_tokens: Some(80),
cached_input_tokens: Some(20),
input_tokens: Some(100),
output_tokens: Some(40),
total_tokens: Some(140),
}],
})),
));
let first_rendered = lines_to_single_string(&first_cell.display_lines(/*width*/ 90));
assert!(first_rendered.contains("50 credits"));
assert!(first_rendered.contains("GPT-5.4 100%"));
assert!(!first_rendered.contains("~$"));
drain_insert_history(&mut rx);
chat.dispatch_command(SlashCommand::Status);
let rendered = match rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => {
lines_to_single_string(&cell.display_lines(/*width*/ 90))
}
event => panic!("expected cached credits-only /status output, got {event:?}"),
};
assert!(rendered.contains("50 credits"));
assert!(rendered.contains("GPT-5.4 100%"));
assert!(rendered.contains("High 100%"));
assert!(rendered.contains("Fast mode 100%"));
assert!(rendered.contains("100 input (20 cached) + 40 output"));
assert!(!rendered.contains("~$"));
let thread_usage = rendered
.lines()
.skip_while(|line| !line.contains("Thread usage:"))
.collect::<Vec<_>>()
.join("\n");
assert_chatwidget_snapshot!(
"status_command_estimated_thread_usage_without_usd",
thread_usage
);
}
#[tokio::test]
async fn status_command_drops_stale_usd_when_updated_usage_has_only_credits() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
chat.plan_type = Some(PlanType::Business);
chat.dispatch_command(SlashCommand::Status);
assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_)));
let request_id = match rx.try_recv() {
Ok(AppEvent::RefreshThreadUsage { request_id, .. }) => request_id,
event => panic!("expected initial thread usage request, got {event:?}"),
};
drain_insert_history(&mut rx);
assert!(chat.finish_thread_usage_refresh(
thread_id,
request_id,
Ok(ThreadUsageOutcome::Available(ThreadUsage {
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 40_000_000,
estimated_usage_usd_micros: Some(1_820_000),
groups: Vec::new(),
})),
));
drain_insert_history(&mut rx);
chat.dispatch_command(SlashCommand::Status);
let cached = match rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => {
lines_to_single_string(&cell.display_lines(/*width*/ 90))
}
event => panic!("expected cached /status output, got {event:?}"),
};
assert!(cached.contains("40 credits · ~$1.82"));
let request_id = match rx.try_recv() {
Ok(AppEvent::RefreshThreadUsage { request_id, .. }) => request_id,
event => panic!("expected refreshed thread usage request, got {event:?}"),
};
drain_insert_history(&mut rx);
assert!(chat.finish_thread_usage_refresh(
thread_id,
request_id,
Ok(ThreadUsageOutcome::Available(ThreadUsage {
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 50_000_000,
estimated_usage_usd_micros: None,
groups: Vec::new(),
})),
));
drain_insert_history(&mut rx);
chat.dispatch_command(SlashCommand::Status);
let refreshed = match rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => {
lines_to_single_string(&cell.display_lines(/*width*/ 90))
}
event => panic!("expected credits-only /status output, got {event:?}"),
};
assert!(refreshed.contains("50 credits"));
assert!(!refreshed.contains("~$1.82"));
}
#[tokio::test]
async fn status_command_requests_thread_usage_for_remote_connection_metadata() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
chat.update_account_state(
/*status_account_display*/ None,
Some(PlanType::Business),
/*has_chatgpt_account*/ false,
/*has_codex_backend_auth*/ true,
);
chat.remote_connection = Some(crate::status::remote_connection::RemoteConnectionStatus {
address: "wss://remote.example.com".to_string(),
version: "v1.0.0".to_string(),
});
chat.dispatch_command(SlashCommand::Status);
assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_)));
assert_matches!(rx.try_recv(), Ok(AppEvent::RefreshThreadUsage { .. }));
}
#[tokio::test]
async fn status_command_requests_thread_usage_with_backend_only_authentication() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
chat.update_account_state(
/*status_account_display*/ None,
Some(PlanType::Business),
/*has_chatgpt_account*/ false,
/*has_codex_backend_auth*/ true,
);
assert!(!chat.has_chatgpt_account());
assert!(chat.has_codex_backend_auth());
chat.dispatch_command(SlashCommand::Status);
assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_)));
assert_matches!(rx.try_recv(), Ok(AppEvent::RefreshThreadUsage { .. }));
}
#[tokio::test]
async fn status_command_remains_visible_when_account_changes_during_usage_refresh() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
chat.plan_type = Some(PlanType::Business);
chat.dispatch_command(SlashCommand::Status);
assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_)));
let request_id = match rx.try_recv() {
Ok(AppEvent::RefreshThreadUsage { request_id, .. }) => request_id,
event => panic!("expected thread usage request, got {event:?}"),
};
drain_insert_history(&mut rx);
chat.update_account_state(
/*status_account_display*/ None, /*plan_type*/ None,
/*has_chatgpt_account*/ false, /*has_codex_backend_auth*/ false,
);
assert!(!chat.finish_thread_usage_refresh(
thread_id,
request_id,
Ok(ThreadUsageOutcome::Available(ThreadUsage {
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 50_000_000,
estimated_usage_usd_micros: Some(1_820_000),
groups: Vec::new(),
})),
));
assert!(drain_insert_history(&mut rx).is_empty());
}
#[tokio::test]
async fn status_command_remains_visible_when_thread_changes_during_usage_refresh() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
chat.plan_type = Some(PlanType::Business);
chat.dispatch_command(SlashCommand::Status);
assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_)));
let request_id = match rx.try_recv() {
Ok(AppEvent::RefreshThreadUsage { request_id, .. }) => request_id,
event => panic!("expected thread usage request, got {event:?}"),
};
drain_insert_history(&mut rx);
chat.thread_id = Some(ThreadId::new());
chat.clear_thread_usage_state();
assert!(!chat.finish_thread_usage_refresh(
thread_id,
request_id,
Ok(ThreadUsageOutcome::Available(ThreadUsage {
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 50_000_000,
estimated_usage_usd_micros: Some(1_820_000),
groups: Vec::new(),
})),
));
assert!(drain_insert_history(&mut rx).is_empty());
}
+135
View File
@@ -0,0 +1,135 @@
//! Demand-driven estimated-cost state for the currently visible enterprise thread.
use super::AppEvent;
use super::ChatWidget;
use super::PlanType;
use super::ThreadId;
use crate::status::StatusHistoryHandle;
use codex_app_server_protocol::ThreadUsage;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum ThreadUsageOutcome {
Available(ThreadUsage),
Disabled,
}
#[derive(Debug, Default)]
pub(super) struct ThreadUsageState {
thread_id: Option<ThreadId>,
estimate: Option<ThreadUsage>,
pending_request_id: Option<u64>,
next_request_id: u64,
feature_disabled: bool,
status_history_handles: Vec<StatusHistoryHandle>,
}
impl ChatWidget {
pub(super) fn clear_thread_usage_state(&mut self) {
let next_request_id = self.thread_usage.next_request_id;
self.thread_usage = ThreadUsageState {
next_request_id,
..ThreadUsageState::default()
};
}
pub(super) fn request_thread_usage_for_status(&mut self, handle: StatusHistoryHandle) {
if !self.thread_usage_is_available() {
return;
}
let Some(thread_id) = self.thread_id else {
return;
};
if self.thread_usage.thread_id != Some(thread_id) {
self.clear_thread_usage_state();
self.thread_usage.thread_id = Some(thread_id);
}
self.thread_usage.status_history_handles.push(handle);
self.request_thread_usage();
}
pub(crate) fn finish_thread_usage_refresh(
&mut self,
thread_id: ThreadId,
request_id: u64,
result: Result<ThreadUsageOutcome, String>,
) -> bool {
if self.thread_id != Some(thread_id)
|| self.thread_usage.thread_id != Some(thread_id)
|| self.thread_usage.pending_request_id != Some(request_id)
{
return false;
}
self.thread_usage.pending_request_id = None;
let mut usage_updated = false;
match result {
Ok(ThreadUsageOutcome::Disabled) => {
self.thread_usage.feature_disabled = true;
self.thread_usage.estimate = None;
usage_updated = true;
}
Ok(ThreadUsageOutcome::Available(usage))
if usage.thread_id != thread_id.to_string() =>
{
tracing::warn!(
requested_thread_id = %thread_id,
returned_thread_id = %usage.thread_id,
"thread usage response referred to another thread"
);
}
Ok(ThreadUsageOutcome::Available(usage)) => {
self.thread_usage.estimate = Some(usage);
usage_updated = true;
}
Err(err) => {
tracing::debug!(error = %err, "failed to fetch estimated thread usage");
}
}
if usage_updated && !self.thread_usage.status_history_handles.is_empty() {
for handle in self.thread_usage.status_history_handles.drain(..) {
handle.set_thread_usage(self.thread_usage.estimate.clone());
}
}
self.request_redraw();
true
}
pub(super) fn estimated_thread_usage(&self) -> Option<&ThreadUsage> {
self.thread_usage.estimate.as_ref()
}
fn request_thread_usage(&mut self) {
let Some(thread_id) = self.thread_id else {
return;
};
if !self.thread_usage_is_available() || self.thread_usage.pending_request_id.is_some() {
return;
}
let request_id = self.thread_usage.next_request_id;
self.thread_usage.next_request_id =
self.thread_usage.next_request_id.wrapping_add(/*rhs*/ 1);
self.thread_usage.pending_request_id = Some(request_id);
self.thread_usage.thread_id = Some(thread_id);
self.app_event_tx.send(AppEvent::RefreshThreadUsage {
thread_id,
request_id,
});
}
pub(super) fn thread_usage_is_available(&self) -> bool {
self.has_codex_backend_auth
&& !self.thread_usage.feature_disabled
&& self.thread_id.is_some()
&& matches!(
self.plan_type,
Some(
PlanType::Business
| PlanType::EnterpriseCbpUsageBased
| PlanType::EnterpriseCbpAutomation
)
)
}
}
@@ -212,6 +212,7 @@ fn summary_snapshot_left_aligns_and_splits_when_needed() {
longest_streak_days: Some(54),
},
daily_usage_buckets: None,
thread_usage: None,
};
let rendered = |width| {
summary_lines(&response, graph_width(width))
@@ -21,6 +21,7 @@ fn loaded_state_freezes_chart_anchor_date_at_completion() {
longest_streak_days: None,
},
daily_usage_buckets: None,
thread_usage: None,
}),
today,
);
+40 -30
View File
@@ -129,36 +129,7 @@ where
// - Non-URL lines also flow through adaptive wrapping; behavior is
// equivalent to standard wrapping when no URL is present.
let wrap_width = area.width.max(1) as usize;
let mut wrapped = Vec::new();
let mut wrapped_rows = 0usize;
for line in lines {
let line_wrapped = match wrap_policy {
HistoryLineWrapPolicy::Terminal => vec![line.clone()],
HistoryLineWrapPolicy::PreWrap
if line_contains_url_like(&line.line)
&& !line_has_mixed_url_and_non_url_tokens(&line.line) =>
{
vec![line.clone()]
}
HistoryLineWrapPolicy::PreWrap => remap_wrapped_line(
line,
adaptive_wrap_line(
&line.line,
RtOptions::new(wrap_width)
.subsequent_indent(leading_whitespace_prefix(&line.line)),
)
.into_iter()
.map(|line| line_to_static(&line))
.collect(),
),
};
wrapped_rows += line_wrapped
.iter()
.map(|wrapped_line| wrapped_line.width().max(1).div_ceil(wrap_width))
.sum::<usize>();
wrapped.extend(line_wrapped);
}
let (wrapped, wrapped_rows) = wrap_history_hyperlink_lines(lines, wrap_width, wrap_policy);
let wrapped_lines = wrapped_rows as u16;
match mode {
InsertHistoryMode::ZellijRaw => {
@@ -256,6 +227,45 @@ where
Ok(())
}
pub(crate) fn wrap_history_hyperlink_lines(
lines: &[HyperlinkLine],
wrap_width: usize,
wrap_policy: HistoryLineWrapPolicy,
) -> (Vec<HyperlinkLine>, usize) {
let mut wrapped = Vec::new();
let mut wrapped_rows = 0usize;
for line in lines {
let line_wrapped = match wrap_policy {
HistoryLineWrapPolicy::Terminal => vec![line.clone()],
HistoryLineWrapPolicy::PreWrap
if line_contains_url_like(&line.line)
&& !line_has_mixed_url_and_non_url_tokens(&line.line) =>
{
vec![line.clone()]
}
HistoryLineWrapPolicy::PreWrap => remap_wrapped_line(
line,
adaptive_wrap_line(
&line.line,
RtOptions::new(wrap_width)
.subsequent_indent(leading_whitespace_prefix(&line.line)),
)
.into_iter()
.map(|line| line_to_static(&line))
.collect(),
),
};
wrapped_rows += line_wrapped
.iter()
.map(|wrapped_line| wrapped_line.width().max(/*other*/ 1).div_ceil(wrap_width))
.sum::<usize>();
wrapped.extend(line_wrapped);
}
(wrapped, wrapped_rows)
}
pub(crate) fn leading_whitespace_prefix(line: &Line<'_>) -> Line<'static> {
let mut spans = Vec::new();
for span in &line.spans {
+26 -2
View File
@@ -47,6 +47,7 @@ use super::rate_limits::compose_rate_limit_data_many;
use super::rate_limits::format_status_limit_summary;
use super::rate_limits::render_status_limit_progress_bar;
use super::remote_connection::RemoteConnectionStatus;
use super::thread_usage::StatusThreadUsage;
use crate::wrapping::RtOptions;
use crate::wrapping::adaptive_wrap_lines;
use crate::wrapping::word_wrap_lines;
@@ -79,9 +80,14 @@ struct StatusRateLimitState {
#[derive(Debug, Clone)]
pub(crate) struct StatusHistoryHandle {
rate_limit_state: Arc<RwLock<StatusRateLimitState>>,
thread_usage: StatusThreadUsage,
}
impl StatusHistoryHandle {
pub(crate) fn reserve_thread_usage_label_width(&self) {
self.thread_usage.reserve_label_width();
}
pub(crate) fn finish_rate_limit_refresh(
&self,
rate_limits: &[RateLimitSnapshotDisplay],
@@ -100,6 +106,13 @@ impl StatusHistoryHandle {
state.rate_limits = rate_limits;
state.refreshing_rate_limits = false;
}
pub(crate) fn set_thread_usage(
&self,
estimate: Option<codex_app_server_protocol::ThreadUsage>,
) {
self.thread_usage.set_estimate(estimate);
}
}
#[derive(Debug)]
@@ -119,6 +132,7 @@ struct StatusHistoryCell {
forked_from: Option<String>,
token_usage: StatusTokenUsageData,
rate_limit_state: Arc<RwLock<StatusRateLimitState>>,
thread_usage: StatusThreadUsage,
}
#[cfg(test)]
@@ -350,6 +364,7 @@ impl StatusHistoryCell {
refreshing_rate_limits,
}));
let agents_summary = Arc::new(RwLock::new(agents_summary));
let thread_usage = StatusThreadUsage::default();
(
Self {
@@ -368,8 +383,12 @@ impl StatusHistoryCell {
token_usage,
agents_summary,
rate_limit_state: rate_limit_state.clone(),
thread_usage: thread_usage.clone(),
},
StatusHistoryHandle {
rate_limit_state,
thread_usage,
},
StatusHistoryHandle { rate_limit_state },
)
}
@@ -770,8 +789,8 @@ impl HistoryCell for StatusHistoryCell {
if self.token_usage.context_window.is_some() {
push_label(&mut labels, &mut seen, "Context window");
}
self.collect_rate_limit_labels(&rate_limit_state, &mut seen, &mut labels);
self.thread_usage.push_labels(&mut labels, &mut seen);
let formatter = FieldFormatter::from_labels(labels.iter().map(String::as_str));
let value_width = formatter.value_width(available_inner_width);
@@ -860,6 +879,11 @@ impl HistoryCell for StatusHistoryCell {
}
lines.extend(self.rate_limit_lines(&rate_limit_state, available_inner_width, &formatter));
let thread_usage_lines = self.thread_usage.lines(&formatter, value_width);
if !thread_usage_lines.is_empty() {
lines.push(Line::from(Vec::<Span<'static>>::new()));
lines.extend(thread_usage_lines);
}
let content_width = lines.iter().map(line_width).max().unwrap_or(0);
let inner_width = content_width.min(available_inner_width);
+1
View File
@@ -12,6 +12,7 @@ mod format;
mod helpers;
mod rate_limits;
pub(crate) mod remote_connection;
mod thread_usage;
pub(crate) use account::StatusAccountDisplay;
pub(crate) use card::StatusHistoryHandle;
+336
View File
@@ -0,0 +1,336 @@
//! Live-updating, thread-scoped billing details embedded in `/status` history cards.
use super::format::FieldFormatter;
use super::format::push_label;
use super::helpers::format_tokens_compact;
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_lines;
use codex_app_server_protocol::ThreadUsage;
use codex_app_server_protocol::ThreadUsageBreakdownGroup;
use ratatui::prelude::Line;
use ratatui::style::Stylize;
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::sync::Arc;
use std::sync::RwLock;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
const REASONING_ORDER: [&str; 8] = [
"None",
"Minimal",
"Light",
"Medium",
"High",
"Extra High",
"Max",
"Ultra",
];
const SPEED_ORDER: [&str; 3] = ["Fast mode", "Ultrafast", "Standard"];
const MODELS_LABEL: &str = " Models";
const REASONING_LABEL: &str = " Reasoning";
const SPEED_LABEL: &str = " Speed";
const BILLED_TOKENS_LABEL: &str = " Billed tokens";
/// Shared state updates a committed `/status` card when its asynchronous estimate arrives.
#[derive(Clone, Debug, Default)]
pub(crate) struct StatusThreadUsage {
estimate: Arc<RwLock<Option<ThreadUsage>>>,
reserve_label_width: Arc<AtomicBool>,
}
impl StatusThreadUsage {
pub(crate) fn reserve_label_width(&self) {
self.reserve_label_width
.store(/*val*/ true, Ordering::Relaxed);
}
pub(crate) fn set_estimate(&self, estimate: Option<ThreadUsage>) {
#[expect(clippy::expect_used)]
let mut stored_estimate = self
.estimate
.write()
.expect("status history thread-usage state poisoned");
*stored_estimate = estimate;
}
pub(crate) fn push_labels(&self, labels: &mut Vec<String>, seen: &mut BTreeSet<String>) {
// Keep existing card rows stable when asynchronous billing details arrive. Reserving a
// formatter label does not emit a loading placeholder or an empty billing row.
if self.reserve_label_width.load(Ordering::Relaxed) {
push_label(labels, seen, BILLED_TOKENS_LABEL);
}
#[expect(clippy::expect_used)]
let stored_estimate = self
.estimate
.read()
.expect("status history thread-usage state poisoned");
let Some(estimate) = stored_estimate.as_ref() else {
return;
};
push_label(labels, seen, "Thread usage");
if !estimate.groups.is_empty() {
push_label(labels, seen, MODELS_LABEL);
push_label(labels, seen, REASONING_LABEL);
push_label(labels, seen, SPEED_LABEL);
if estimate
.groups
.iter()
.any(|group| group.input_tokens.is_some() || group.output_tokens.is_some())
{
push_label(labels, seen, BILLED_TOKENS_LABEL);
}
}
}
pub(crate) fn lines(
&self,
formatter: &FieldFormatter,
value_width: usize,
) -> Vec<Line<'static>> {
#[expect(clippy::expect_used)]
let stored_estimate = self
.estimate
.read()
.expect("status history thread-usage state poisoned");
let Some(estimate) = stored_estimate.as_ref() else {
return Vec::new();
};
let credits_micros = estimate.estimated_usage_credits_micros;
let mut usage = vec![format!("{} credits", format_credit_micros(credits_micros)).into()];
if let Some(cost) = estimate
.estimated_usage_usd_micros
.and_then(format_estimated_usd_micros)
{
usage.push(" · ".dim());
usage.push(cost.into());
}
let mut lines = vec![formatter.line("Thread usage", usage)];
for (label, dimension) in [
(MODELS_LABEL, BreakdownDimension::Model),
(REASONING_LABEL, BreakdownDimension::Reasoning),
(SPEED_LABEL, BreakdownDimension::Speed),
] {
let Some(value) = grouped_usage(&estimate.groups, dimension) else {
continue;
};
let mut wrapped = word_wrap_lines(
[Line::from(value)],
RtOptions::new(value_width.max(/*other*/ 1)),
)
.into_iter();
if let Some(first) = wrapped.next() {
lines.push(formatter.line(label, first.spans));
lines.extend(wrapped.map(|line| formatter.continuation(line.spans)));
}
}
let sum_tokens = |count: fn(&ThreadUsageBreakdownGroup) -> Option<i64>| {
estimate
.groups
.iter()
.try_fold(/*init*/ 0_i64, |total, group| {
count(group).map(|tokens| total.saturating_add(tokens))
})
};
let input_tokens = sum_tokens(|group| group.input_tokens);
let cached_tokens = sum_tokens(|group| group.cached_input_tokens);
let output_tokens = sum_tokens(|group| group.output_tokens);
if !estimate.groups.is_empty() && (input_tokens.is_some() || output_tokens.is_some()) {
let mut tokens = Vec::new();
if let Some(input_tokens) = input_tokens {
tokens.push(format!("{} input", format_tokens_compact(input_tokens)));
if let Some(cached_tokens) = cached_tokens {
tokens.push(format!("({} cached)", format_tokens_compact(cached_tokens)));
}
}
if let Some(output_tokens) = output_tokens {
if !tokens.is_empty() {
tokens.push("+".to_string());
}
tokens.push(format!("{} output", format_tokens_compact(output_tokens)));
}
lines.push(formatter.line(BILLED_TOKENS_LABEL, vec![tokens.join(" ").into()]));
}
lines
}
}
#[derive(Clone, Copy)]
enum BreakdownDimension {
Model,
Reasoning,
Speed,
}
fn grouped_usage(
groups: &[ThreadUsageBreakdownGroup],
dimension: BreakdownDimension,
) -> Option<String> {
let mut totals = BTreeMap::<String, i64>::new();
for group in groups {
if group.estimated_usage_credits_micros < 0 {
continue;
}
let value = match dimension {
BreakdownDimension::Model => group
.model
.as_deref()
.map(format_model_display_name)
.unwrap_or_else(|| "Other".to_string()),
BreakdownDimension::Reasoning => match group.reasoning_effort.as_deref() {
Some("none") => "None",
Some("minimal") => "Minimal",
Some("low") => "Light",
Some("medium") => "Medium",
Some("high") => "High",
Some("xhigh") => "Extra High",
Some("max") => "Max",
Some("ultra") => "Ultra",
Some(other) => other,
None => "Other",
}
.to_string(),
BreakdownDimension::Speed => match group.speed.as_deref() {
Some("fast") => "Fast mode",
Some("ultrafast") => "Ultrafast",
Some("standard") => "Standard",
Some(other) => other,
None => "Other",
}
.to_string(),
};
let total = totals.entry(value).or_default();
*total = total.saturating_add(group.estimated_usage_credits_micros);
}
if totals.is_empty() {
return None;
}
let total_credits_micros =
totals.values().fold(
/*init*/ 0_i64,
|total, credits| total.saturating_add(*credits),
);
let mut totals = totals.into_iter().collect::<Vec<_>>();
totals.sort_by(
|(left_name, left_credits), (right_name, right_credits)| match dimension {
BreakdownDimension::Model => right_credits
.cmp(left_credits)
.then_with(|| left_name.cmp(right_name)),
BreakdownDimension::Reasoning => REASONING_ORDER
.iter()
.position(|value| value == left_name)
.unwrap_or(usize::MAX)
.cmp(
&REASONING_ORDER
.iter()
.position(|value| value == right_name)
.unwrap_or(usize::MAX),
)
.then_with(|| left_name.cmp(right_name)),
BreakdownDimension::Speed => SPEED_ORDER
.iter()
.position(|value| value == left_name)
.unwrap_or(usize::MAX)
.cmp(
&SPEED_ORDER
.iter()
.position(|value| value == right_name)
.unwrap_or(usize::MAX),
)
.then_with(|| left_name.cmp(right_name)),
},
);
Some(
totals
.into_iter()
.map(|(name, credits)| {
let percentage = if total_credits_micros <= 0 {
0
} else {
(i128::from(credits) * 100 + i128::from(total_credits_micros) / 2)
/ i128::from(total_credits_micros)
};
format!("{name} {percentage}%")
})
.collect::<Vec<_>>()
.join(", "),
)
}
fn format_model_display_name(model: &str) -> String {
let Some((prefix, remainder)) = model.split_once('-') else {
return model.to_string();
};
if !prefix.eq_ignore_ascii_case("gpt") || remainder.is_empty() {
return model.to_string();
}
let mut segments = remainder.split('-');
let first = segments.next().unwrap_or_default();
let mut display_name = format!("GPT-{first}");
let numeric_model = first
.chars()
.next()
.is_some_and(|character| character.is_ascii_digit());
for segment in segments {
display_name.push(if numeric_model { ' ' } else { '-' });
if segment.eq_ignore_ascii_case("oai") {
display_name.push_str("OAI");
continue;
}
let mut characters = segment.chars();
if let Some(first_character) = characters.next() {
display_name.extend(first_character.to_uppercase());
display_name.push_str(characters.as_str());
}
}
display_name
}
pub(crate) fn format_credit_micros(micros: i64) -> String {
let micros = i128::from(micros.max(/*other*/ 0));
let (unit_micros, suffix) = [
(1_000_000_000_000_000_000_i128, "T"),
(1_000_000_000_000_000_i128, "B"),
(1_000_000_000_000_i128, "M"),
(1_000_000_000_i128, "K"),
]
.into_iter()
.find(|(unit, _)| micros >= *unit)
.unwrap_or((1_000_000, ""));
let tenths = (micros * 10 + unit_micros / 2) / unit_micros;
let whole = tenths / 10;
let fraction = tenths % 10;
if fraction == 0 {
format!("{whole}{suffix}")
} else {
format!("{whole}.{fraction}{suffix}")
}
}
pub(crate) fn format_estimated_usd_micros(micros: i64) -> Option<String> {
if micros < 0 {
return None;
}
if micros > 0 && micros < 100 {
return Some(format!("~$0.{micros:06}"));
}
if micros > 0 && micros < 10_000 {
let ten_thousandths = micros.saturating_add(/*rhs*/ 50) / 100;
return Some(format!("~$0.{ten_thousandths:04}"));
}
let cents = micros.saturating_add(/*rhs*/ 5_000) / 10_000;
Some(format!("~${}.{:02}", cents / 100, cents % 100))
}
#[cfg(test)]
#[path = "thread_usage_tests.rs"]
mod tests;
@@ -0,0 +1,70 @@
use super::BreakdownDimension;
use super::format_credit_micros;
use super::format_model_display_name;
use super::grouped_usage;
use codex_app_server_protocol::ThreadUsageBreakdownGroup;
use pretty_assertions::assert_eq;
#[test]
fn thread_usage_breakdown_groups_aggregate_model_credit_percentages() {
let group = ThreadUsageBreakdownGroup {
model: Some("gpt-5.4".to_string()),
reasoning_effort: Some("high".to_string()),
speed: Some("fast".to_string()),
estimated_usage_credits_micros: 30_000_000,
net_new_input_tokens: Some(60),
cached_input_tokens: Some(10),
input_tokens: Some(70),
output_tokens: Some(20),
total_tokens: Some(90),
};
let groups = vec![
group.clone(),
ThreadUsageBreakdownGroup {
reasoning_effort: Some("medium".to_string()),
speed: Some("standard".to_string()),
estimated_usage_credits_micros: 10_000_000,
..group.clone()
},
ThreadUsageBreakdownGroup {
model: Some("gpt-5-mini".to_string()),
reasoning_effort: Some("low".to_string()),
speed: Some("standard".to_string()),
estimated_usage_credits_micros: 10_000_000,
..group
},
];
assert_eq!(
grouped_usage(&groups, BreakdownDimension::Model),
Some("GPT-5.4 80%, GPT-5 Mini 20%".to_string())
);
assert_eq!(
grouped_usage(&groups, BreakdownDimension::Reasoning),
Some("Light 20%, Medium 20%, High 60%".to_string())
);
assert_eq!(
grouped_usage(&groups, BreakdownDimension::Speed),
Some("Fast mode 60%, Standard 40%".to_string())
);
}
#[test]
fn thread_usage_credit_formatting_rounds_to_one_decimal_place() {
assert_eq!(format_credit_micros(/*micros*/ 0), "0");
assert_eq!(format_credit_micros(/*micros*/ 400_000), "0.4");
assert_eq!(format_credit_micros(/*micros*/ 5_200_000), "5.2");
assert_eq!(format_credit_micros(/*micros*/ 5_240_000), "5.2");
assert_eq!(format_credit_micros(/*micros*/ 5_250_000), "5.3");
assert_eq!(format_credit_micros(/*micros*/ 46_000_000), "46");
assert_eq!(format_credit_micros(/*micros*/ 1_240_000_000), "1.2K");
assert_eq!(format_credit_micros(/*micros*/ 12_400_000_000), "12.4K");
}
#[test]
fn thread_usage_model_names_match_desktop_display_names() {
assert_eq!(format_model_display_name("gpt-5.4"), "GPT-5.4");
assert_eq!(format_model_display_name("gpt-5-mini"), "GPT-5 Mini");
assert_eq!(format_model_display_name("gpt-5.6-sol"), "GPT-5.6 Sol");
assert_eq!(format_model_display_name("mewfour"), "mewfour");
}
+9 -1
View File
@@ -25,9 +25,17 @@ pub struct VT100Backend {
impl VT100Backend {
/// Creates a new `TestBackend` with the specified width and height.
pub fn new(width: u16, height: u16) -> Self {
Self::with_scrollback(width, height, /*scrollback_len*/ 0)
}
pub fn with_scrollback(width: u16, height: u16, scrollback_len: usize) -> Self {
crossterm::style::force_color_output(true);
Self {
crossterm_backend: CrosstermBackend::new(vt100::Parser::new(height, width, 0)),
crossterm_backend: CrosstermBackend::new(vt100::Parser::new(
height,
width,
scrollback_len,
)),
}
}
+1
View File
@@ -60,6 +60,7 @@ use codex_config::types::NotificationMethod;
mod event_stream;
mod frame_rate_limiter;
mod frame_requester;
mod history_tail;
#[cfg(unix)]
mod job_control;
mod keyboard_modes;
+89
View File
@@ -0,0 +1,89 @@
//! Update visible terminal-history tails without discarding terminal scrollback.
use super::Tui;
use crate::custom_terminal::Terminal as CustomTerminal;
use crate::insert_history::HistoryLineWrapPolicy;
use crate::insert_history::InsertHistoryMode;
use crate::insert_history::insert_history_hyperlink_lines_with_mode_and_wrap_policy;
use crate::insert_history::wrap_history_hyperlink_lines;
use crate::terminal_hyperlinks::HyperlinkLine;
use ratatui::backend::Backend;
use ratatui::layout::Position;
use std::io;
use std::io::Write;
impl Tui {
#[cfg(test)]
pub(crate) fn pending_history_lines_for_test(&self) -> Vec<HyperlinkLine> {
self.pending_history_lines
.iter()
.flat_map(|batch| batch.lines.iter().cloned())
.collect()
}
pub(crate) fn replace_visible_history_tail(
&mut self,
previous_lines: &[HyperlinkLine],
replacement: &[HyperlinkLine],
wrap_policy: HistoryLineWrapPolicy,
) -> io::Result<bool> {
Self::flush_pending_history_lines(
&mut self.terminal,
&mut self.pending_history_lines,
self.is_zellij,
)?;
let mode = if self.is_zellij && wrap_policy == HistoryLineWrapPolicy::Terminal {
InsertHistoryMode::ZellijRaw
} else {
InsertHistoryMode::Standard
};
let replaced = replace_visible_terminal_history_tail(
&mut self.terminal,
previous_lines,
replacement,
mode,
wrap_policy,
)?;
if replaced {
self.frame_requester().schedule_frame();
}
Ok(replaced)
}
}
fn replace_visible_terminal_history_tail<B>(
terminal: &mut CustomTerminal<B>,
previous_lines: &[HyperlinkLine],
replacement: &[HyperlinkLine],
mode: InsertHistoryMode,
wrap_policy: HistoryLineWrapPolicy,
) -> io::Result<bool>
where
B: Backend<Error = io::Error> + Write,
{
let mut viewport = terminal.viewport_area;
let wrap_width = usize::from(viewport.width.max(/*other*/ 1));
let (_, previous_rows) = wrap_history_hyperlink_lines(previous_lines, wrap_width, wrap_policy);
let Ok(previous_rows) = u16::try_from(previous_rows) else {
return Ok(false);
};
if previous_rows == 0 || previous_rows > viewport.top() {
return Ok(false);
}
viewport.y -= previous_rows;
terminal.clear_after_position(Position::new(/*x*/ 0, viewport.y))?;
terminal.set_viewport_area(viewport);
terminal.invalidate_viewport();
insert_history_hyperlink_lines_with_mode_and_wrap_policy(
terminal,
replacement,
mode,
wrap_policy,
)?;
Ok(true)
}
#[cfg(test)]
#[path = "history_tail_tests.rs"]
mod tests;
+144
View File
@@ -0,0 +1,144 @@
use super::replace_visible_terminal_history_tail;
use crate::custom_terminal::Terminal;
use crate::insert_history::HistoryLineWrapPolicy;
use crate::insert_history::InsertHistoryMode;
use crate::insert_history::insert_history_lines;
use crate::terminal_hyperlinks::plain_hyperlink_lines;
use crate::test_backend::VT100Backend;
use pretty_assertions::assert_eq;
use ratatui::layout::Rect;
use ratatui::text::Line;
use std::io::Write;
#[test]
fn replacing_visible_history_tail_preserves_existing_terminal_scrollback() {
let width = 52;
let height = 8;
let backend = VT100Backend::with_scrollback(width, height, /*scrollback_len*/ 32);
let mut terminal = Terminal::with_options(backend).expect("terminal");
for line in [
"shell history that predates Codex",
"shell output one",
"shell output two",
"shell output three",
"shell output four",
"shell output five",
"shell output six",
"shell output seven",
] {
writeln!(terminal.backend_mut(), "{line}\r").expect("seed shell history");
}
terminal.set_viewport_area(Rect::new(
/*x*/ 0,
/*y*/ height - 2,
width,
/*height*/ 2,
));
insert_history_lines(
&mut terminal,
vec![
Line::from("/status"),
Line::from("Account: business"),
Line::from("old closing border"),
],
)
.expect("insert initial status card");
let previous_lines = plain_hyperlink_lines(vec![Line::from("old closing border")]);
let replacement = plain_hyperlink_lines(vec![
Line::from("Thread usage: 50 credits"),
Line::from("new closing border"),
]);
assert!(
replace_visible_terminal_history_tail(
&mut terminal,
&previous_lines,
&replacement,
InsertHistoryMode::Standard,
HistoryLineWrapPolicy::PreWrap,
)
.expect("replace status-card tail")
);
let contents = terminal.backend().vt100().screen().contents();
assert!(contents.contains("/status"), "{contents}");
assert!(contents.contains("Account: business"), "{contents}");
assert!(contents.contains("Thread usage: 50 credits"), "{contents}");
assert!(!contents.contains("old closing border"), "{contents}");
let mut screen_with_scrollback = terminal.backend().vt100().screen().clone();
screen_with_scrollback.set_scrollback(/*rows*/ usize::MAX);
let scrollback = screen_with_scrollback.contents();
assert!(
scrollback.contains("shell history that predates Codex"),
"existing terminal scrollback was discarded: {scrollback}"
);
}
#[test]
fn inaccessible_history_tail_is_preserved_for_non_destructive_append() {
let width = 36;
let height = 6;
let backend = VT100Backend::with_scrollback(width, height, /*scrollback_len*/ 16);
let mut terminal = Terminal::with_options(backend).expect("terminal");
terminal.set_viewport_area(Rect::new(
/*x*/ 0,
/*y*/ 1,
width,
/*height*/ height - 1,
));
let before = terminal.backend().vt100().screen().contents();
let previous_lines =
plain_hyperlink_lines(vec![Line::from("first line"), Line::from("second line")]);
let replacement = plain_hyperlink_lines(vec![Line::from("Thread usage: 50 credits")]);
assert!(
!replace_visible_terminal_history_tail(
&mut terminal,
&previous_lines,
&replacement,
InsertHistoryMode::Standard,
HistoryLineWrapPolicy::PreWrap,
)
.expect("leave inaccessible history untouched")
);
assert_eq!(terminal.backend().vt100().screen().contents(), before);
}
#[test]
fn replacing_soft_wrapped_history_counts_physical_terminal_rows() {
let width = 12;
let height = 8;
let backend = VT100Backend::new(width, height);
let mut terminal = Terminal::with_options(backend).expect("terminal");
terminal.set_viewport_area(Rect::new(
/*x*/ 0,
/*y*/ height - 2,
width,
/*height*/ 2,
));
let previous_lines = plain_hyperlink_lines(vec![Line::from("old-long-line-spanning-two-rows")]);
crate::insert_history::insert_history_hyperlink_lines_with_mode_and_wrap_policy(
&mut terminal,
&previous_lines,
InsertHistoryMode::Standard,
HistoryLineWrapPolicy::Terminal,
)
.expect("insert soft-wrapped history");
let replacement = plain_hyperlink_lines(vec![Line::from("new billing")]);
assert!(
replace_visible_terminal_history_tail(
&mut terminal,
&previous_lines,
&replacement,
InsertHistoryMode::Standard,
HistoryLineWrapPolicy::Terminal,
)
.expect("replace physically wrapped rows")
);
let contents = terminal.backend().vt100().screen().contents();
assert!(contents.contains("new billing"), "{contents}");
assert!(!contents.contains("old-long"), "{contents}");
assert!(!contents.contains("spanning"), "{contents}");
}