Add organization and group name(s) to /status cell

This commit is contained in:
canvrno-oai
2026-04-14 19:51:12 -07:00
parent ca650561d6
commit 705d759a34
26 changed files with 951 additions and 29 deletions
@@ -965,6 +965,14 @@
],
"type": "object"
},
"GetAccountMetadataParams": {
"properties": {
"eligibilityOnly": {
"type": "boolean"
}
},
"type": "object"
},
"GetAccountParams": {
"properties": {
"refreshToken": {
@@ -4890,6 +4898,30 @@
"title": "Account/rateLimits/readRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"account/metadata/read"
],
"title": "Account/metadata/readRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/GetAccountMetadataParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Account/metadata/readRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -1416,6 +1416,30 @@
"title": "Account/rateLimits/readRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"account/metadata/read"
],
"title": "Account/metadata/readRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/GetAccountMetadataParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Account/metadata/readRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -8094,6 +8118,44 @@
}
]
},
"GetAccountMetadataParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"eligibilityOnly": {
"type": "boolean"
}
},
"title": "GetAccountMetadataParams",
"type": "object"
},
"GetAccountMetadataResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"accountDisplayName": {
"type": [
"string",
"null"
]
},
"accountGroupNames": {
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
},
"metadataFetchEligible": {
"type": "boolean"
}
},
"required": [
"metadataFetchEligible"
],
"title": "GetAccountMetadataResponse",
"type": "object"
},
"GetAccountParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -1998,6 +1998,30 @@
"title": "Account/rateLimits/readRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"account/metadata/read"
],
"title": "Account/metadata/readRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/GetAccountMetadataParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Account/metadata/readRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -4822,6 +4846,44 @@
"title": "FuzzyFileSearchSessionUpdatedNotification",
"type": "object"
},
"GetAccountMetadataParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"eligibilityOnly": {
"type": "boolean"
}
},
"title": "GetAccountMetadataParams",
"type": "object"
},
"GetAccountMetadataResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"accountDisplayName": {
"type": [
"string",
"null"
]
},
"accountGroupNames": {
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
},
"metadataFetchEligible": {
"type": "boolean"
}
},
"required": [
"metadataFetchEligible"
],
"title": "GetAccountMetadataResponse",
"type": "object"
},
"GetAccountParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -0,0 +1,10 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"eligibilityOnly": {
"type": "boolean"
}
},
"title": "GetAccountMetadataParams",
"type": "object"
}
@@ -0,0 +1,28 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"accountDisplayName": {
"type": [
"string",
"null"
]
},
"accountGroupNames": {
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
},
"metadataFetchEligible": {
"type": "boolean"
}
},
"required": [
"metadataFetchEligible"
],
"title": "GetAccountMetadataResponse",
"type": "object"
}
File diff suppressed because one or more lines are too long
@@ -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 GetAccountMetadataParams = { eligibilityOnly?: boolean, };
@@ -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 GetAccountMetadataResponse = { metadataFetchEligible: boolean, accountDisplayName: string | null, accountGroupNames: Array<string> | null, };
@@ -118,6 +118,8 @@ export type { FsWatchParams } from "./FsWatchParams";
export type { FsWatchResponse } from "./FsWatchResponse";
export type { FsWriteFileParams } from "./FsWriteFileParams";
export type { FsWriteFileResponse } from "./FsWriteFileResponse";
export type { GetAccountMetadataParams } from "./GetAccountMetadataParams";
export type { GetAccountMetadataResponse } from "./GetAccountMetadataResponse";
export type { GetAccountParams } from "./GetAccountParams";
export type { GetAccountRateLimitsResponse } from "./GetAccountRateLimitsResponse";
export type { GetAccountResponse } from "./GetAccountResponse";
@@ -515,6 +515,11 @@ client_request_definitions! {
response: v2::GetAccountRateLimitsResponse,
},
GetAccountMetadata => "account/metadata/read" {
params: v2::GetAccountMetadataParams,
response: v2::GetAccountMetadataResponse,
},
FeedbackUpload => "feedback/upload" {
params: v2::FeedbackUploadParams,
response: v2::FeedbackUploadResponse,
@@ -1762,6 +1762,23 @@ pub struct GetAccountRateLimitsResponse {
pub rate_limits_by_limit_id: Option<HashMap<String, RateLimitSnapshot>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct GetAccountMetadataParams {
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub eligibility_only: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct GetAccountMetadataResponse {
pub metadata_fetch_eligible: bool,
pub account_display_name: Option<String>,
pub account_group_names: Option<Vec<String>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
+15
View File
@@ -1437,6 +1437,21 @@ Field notes:
- `refreshToken` (bool): set `true` to force a token refresh.
- `requiresOpenaiAuth` reflects the active provider; when `false`, Codex can run without OpenAI credentials.
### 1a) Read ChatGPT account metadata
```json
{ "method": "account/metadata/read", "params": { "eligibilityOnly": false }, "id": 2 }
```
Response example:
```json
{ "id": 2, "result": { "metadataFetchEligible": true, "accountDisplayName": "Acme Corp", "accountGroupNames": ["Engineering"] } }
```
The metadata response returns nullable fields and may be empty for non-workspace accounts or providers outside the first-party OpenAI ChatGPT flow.
Set `eligibilityOnly` to `true` to check whether a live metadata fetch would run without making the live metadata requests.
### 2) Log in with an API key
1. Send:
@@ -63,6 +63,8 @@ use codex_app_server_protocol::FuzzyFileSearchSessionStopParams;
use codex_app_server_protocol::FuzzyFileSearchSessionStopResponse;
use codex_app_server_protocol::FuzzyFileSearchSessionUpdateParams;
use codex_app_server_protocol::FuzzyFileSearchSessionUpdateResponse;
use codex_app_server_protocol::GetAccountMetadataParams;
use codex_app_server_protocol::GetAccountMetadataResponse;
use codex_app_server_protocol::GetAccountParams;
use codex_app_server_protocol::GetAccountRateLimitsResponse;
use codex_app_server_protocol::GetAccountResponse;
@@ -382,6 +384,19 @@ const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60);
const LOGIN_ISSUER_OVERRIDE_ENV_VAR: &str = "CODEX_APP_SERVER_LOGIN_ISSUER";
const APP_LIST_LOAD_TIMEOUT: Duration = Duration::from_secs(90);
const THREAD_UNLOADING_DELAY: Duration = Duration::from_secs(30 * 60);
const ACCOUNT_METADATA_FETCH_TIMEOUT: Duration = Duration::from_secs(2);
fn account_metadata_response(
metadata_fetch_eligible: bool,
account_display_name: Option<String>,
account_group_names: Option<Vec<String>>,
) -> GetAccountMetadataResponse {
GetAccountMetadataResponse {
metadata_fetch_eligible,
account_display_name,
account_group_names,
}
}
enum ActiveLogin {
Browser {
@@ -1164,6 +1179,10 @@ impl CodexMessageProcessor {
self.get_account_rate_limits(to_connection_request_id(request_id))
.await;
}
ClientRequest::GetAccountMetadata { request_id, params } => {
self.get_account_metadata(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::FeedbackUpload { request_id, params } => {
self.upload_feedback(to_connection_request_id(request_id), params)
.await;
@@ -1875,6 +1894,202 @@ impl CodexMessageProcessor {
}
}
async fn get_account_metadata(
&self,
request_id: ConnectionRequestId,
params: GetAccountMetadataParams,
) {
match self.fetch_account_metadata(params).await {
Ok(response) => {
self.outgoing.send_response(request_id, response).await;
}
Err(error) => {
self.outgoing.send_error(request_id, error).await;
}
}
}
async fn fetch_account_metadata(
&self,
params: GetAccountMetadataParams,
) -> Result<GetAccountMetadataResponse, JSONRPCErrorError> {
tracing::info!(
target: "codex_tui::status_metadata",
eligibility_only = params.eligibility_only,
"app-server account metadata request started"
);
let Some((client, account_id, chatgpt_user_id)) =
self.account_metadata_fetch_context().await?
else {
tracing::info!(
target: "codex_tui::status_metadata",
"app-server account metadata request ineligible"
);
return Ok(account_metadata_response(
/*metadata_fetch_eligible*/ false, /*account_display_name*/ None,
/*account_group_names*/ None,
));
};
if params.eligibility_only {
tracing::info!(
target: "codex_tui::status_metadata",
account_id_present = account_id.is_some(),
chatgpt_user_id_present = chatgpt_user_id.is_some(),
"app-server account metadata eligibility check passed"
);
return Ok(account_metadata_response(
/*metadata_fetch_eligible*/ true, /*account_display_name*/ None,
/*account_group_names*/ None,
));
}
let account_display_name = match tokio::time::timeout(
ACCOUNT_METADATA_FETCH_TIMEOUT,
client.get_current_account_display_name(),
)
.await
{
Ok(Ok(name)) => name,
Ok(Err(err)) => {
return Err(JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to fetch ChatGPT account display name: {err}"),
data: None,
});
}
Err(err) => {
return Err(JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("timed out fetching ChatGPT account display name: {err}"),
data: None,
});
}
};
tracing::info!(
target: "codex_tui::status_metadata",
display_name_present = account_display_name.is_some(),
"app-server account display name fetch completed"
);
let account_group_names = if account_display_name.is_some() {
match (account_id.as_deref(), chatgpt_user_id.as_deref()) {
(Some(account_id), Some(chatgpt_user_id)) => match tokio::time::timeout(
ACCOUNT_METADATA_FETCH_TIMEOUT,
client.get_current_account_group_names(account_id, chatgpt_user_id),
)
.await
{
Ok(Ok(names)) => {
tracing::info!(
target: "codex_tui::status_metadata",
group_count = names.as_ref().map_or(0, std::vec::Vec::len),
"app-server account groups fetch completed"
);
names
}
Ok(Err(err)) => {
tracing::warn!(
target: "codex_tui::status_metadata",
"failed to fetch ChatGPT account groups: {err}"
);
None
}
Err(err) => {
tracing::warn!(
target: "codex_tui::status_metadata",
"timed out fetching ChatGPT account groups: {err}"
);
None
}
},
_ => {
tracing::info!(
target: "codex_tui::status_metadata",
account_id_present = account_id.is_some(),
chatgpt_user_id_present = chatgpt_user_id.is_some(),
"skipping account groups fetch: missing identifiers"
);
None
}
}
} else {
tracing::info!(
target: "codex_tui::status_metadata",
"skipping account groups fetch: missing display name"
);
None
};
tracing::info!(
target: "codex_tui::status_metadata",
display_name_present = account_display_name.is_some(),
group_count = account_group_names.as_ref().map_or(0, std::vec::Vec::len),
"app-server account metadata request completed"
);
Ok(account_metadata_response(
/*metadata_fetch_eligible*/ true,
account_display_name,
account_group_names,
))
}
async fn account_metadata_fetch_context(
&self,
) -> Result<Option<(BackendClient, Option<String>, Option<String>)>, JSONRPCErrorError> {
if self.config.model_provider.name != "OpenAI" {
tracing::info!(
target: "codex_tui::status_metadata",
provider_name = %self.config.model_provider.name,
"account metadata ineligible: non-OpenAI provider"
);
return Ok(None);
}
let Some(auth) = self.auth_manager.auth().await else {
tracing::info!(
target: "codex_tui::status_metadata",
"account metadata ineligible: no auth"
);
return Ok(None);
};
if !matches!(auth.auth_mode(), CoreAuthMode::Chatgpt) {
tracing::info!(
target: "codex_tui::status_metadata",
"account metadata ineligible: auth mode is not ChatGPT"
);
return Ok(None);
}
let token_data = auth.get_token_data().ok();
let is_workspace_account = token_data
.as_ref()
.is_some_and(|token_data| token_data.id_token.is_workspace_account());
if !is_workspace_account {
tracing::info!(
target: "codex_tui::status_metadata",
token_data_present = token_data.is_some(),
"account metadata ineligible: not a workspace account"
);
return Ok(None);
}
let account_id = auth.get_account_id();
let chatgpt_user_id = auth.get_chatgpt_user_id();
tracing::info!(
target: "codex_tui::status_metadata",
account_id_present = account_id.is_some(),
chatgpt_user_id_present = chatgpt_user_id.is_some(),
"account metadata fetch context built"
);
let client = BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth)
.map_err(|err| JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to construct backend client: {err}"),
data: None,
})?;
Ok(Some((client, account_id, chatgpt_user_id)))
}
async fn fetch_account_rate_limits(
&self,
) -> Result<
+68
View File
@@ -18,6 +18,7 @@ use reqwest::header::HeaderMap;
use reqwest::header::HeaderName;
use reqwest::header::HeaderValue;
use reqwest::header::USER_AGENT;
use serde::Deserialize;
use serde::de::DeserializeOwned;
use std::fmt;
@@ -87,6 +88,26 @@ pub enum PathStyle {
ChatGptApi,
}
#[derive(Debug, Deserialize)]
struct CurrentAccountOptimizedResponse {
account: CurrentAccountOptimizedAccount,
}
#[derive(Debug, Deserialize)]
struct CurrentAccountOptimizedAccount {
name: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AccountGroupsResponse {
items: Vec<AccountGroup>,
}
#[derive(Debug, Deserialize)]
struct AccountGroup {
name: String,
}
impl PathStyle {
pub fn from_base_url(base_url: &str) -> Self {
if base_url.contains("/backend-api") {
@@ -265,6 +286,53 @@ impl Client {
Ok(Self::rate_limit_snapshots_from_payload(payload))
}
pub async fn get_current_account_display_name(&self) -> Result<Option<String>> {
let url = match self.path_style {
PathStyle::CodexApi => {
anyhow::bail!("current account display name is only available from ChatGPT API")
}
PathStyle::ChatGptApi => format!("{}/accounts/optimized/check", self.base_url),
};
let req = self.http.get(&url).headers(self.headers());
let (body, ct) = self.exec_request(req, "GET", &url).await?;
let payload: CurrentAccountOptimizedResponse = self.decode_json(&url, &ct, &body)?;
let display_name = payload
.account
.name
.map(|name| name.trim().to_string())
.filter(|name| !name.is_empty());
Ok(display_name)
}
pub async fn get_current_account_group_names(
&self,
account_id: &str,
chatgpt_user_id: &str,
) -> Result<Option<Vec<String>>> {
let url = match self.path_style {
PathStyle::CodexApi => {
anyhow::bail!("current account groups are only available from ChatGPT API")
}
PathStyle::ChatGptApi => {
let account_user_id = format!("{chatgpt_user_id}__{account_id}");
format!(
"{}/accounts/{account_id}/users/{account_user_id}/groups",
self.base_url
)
}
};
let req = self.http.get(&url).headers(self.headers());
let (body, ct) = self.exec_request(req, "GET", &url).await?;
let payload: AccountGroupsResponse = self.decode_json(&url, &ct, &body)?;
let group_names = payload
.items
.into_iter()
.map(|group| group.name.trim().to_string())
.filter(|name| !name.is_empty())
.collect::<Vec<_>>();
Ok((!group_names.is_empty()).then_some(group_names))
}
pub async fn list_tasks(
&self,
limit: Option<i32>,
+120
View File
@@ -59,6 +59,7 @@ use crate::render::highlight::highlight_bash_to_lines;
use crate::render::renderable::Renderable;
use crate::resume_picker::SessionSelection;
use crate::resume_picker::SessionTarget;
use crate::status::StatusAccountMetadata;
#[cfg(test)]
use crate::test_support::PathBufExt;
#[cfg(test)]
@@ -77,6 +78,8 @@ use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo;
use codex_app_server_protocol::ConfigLayerSource;
use codex_app_server_protocol::FeedbackUploadParams;
use codex_app_server_protocol::FeedbackUploadResponse;
use codex_app_server_protocol::GetAccountMetadataParams;
use codex_app_server_protocol::GetAccountMetadataResponse;
use codex_app_server_protocol::GetAccountRateLimitsResponse;
use codex_app_server_protocol::ListMcpServerStatusParams;
use codex_app_server_protocol::ListMcpServerStatusResponse;
@@ -1942,6 +1945,83 @@ impl App {
});
}
fn refresh_status_metadata(&mut self, app_server: &AppServerSession, request_id: u64) {
tracing::info!(
target: "codex_tui::status_metadata",
"checking status metadata fetch eligibility"
);
let eligibility_request_handle = app_server.request_handle();
let metadata_request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
tokio::spawn(async move {
let result = async {
let metadata_fetch_eligible =
fetch_account_metadata_eligibility(eligibility_request_handle).await?;
tracing::info!(
target: "codex_tui::status_metadata",
eligible = metadata_fetch_eligible,
"status metadata fetch eligibility returned"
);
if !metadata_fetch_eligible {
tracing::info!(
target: "codex_tui::status_metadata",
"skipping status metadata fetch: ineligible"
);
return Ok(None);
}
tracing::info!(
target: "codex_tui::status_metadata",
"starting status metadata fetch"
);
fetch_account_metadata(metadata_request_handle)
.await
.map(Some)
}
.await
.map_err(|err| err.to_string());
app_event_tx.send(AppEvent::StatusMetadataLoaded { request_id, result });
});
}
fn on_status_metadata_loaded(
&mut self,
request_id: u64,
result: Result<Option<GetAccountMetadataResponse>, String>,
) {
let account_metadata = match result {
Ok(Some(metadata)) => {
let group_count = metadata.account_group_names.as_ref().map_or(0, Vec::len);
tracing::info!(
target: "codex_tui::status_metadata",
display_name_present = metadata.account_display_name.is_some(),
group_count,
"status metadata loaded"
);
Some(StatusAccountMetadata {
account_display_name: metadata.account_display_name,
account_group_names: metadata.account_group_names,
})
}
Ok(None) => {
tracing::info!(
target: "codex_tui::status_metadata",
"status metadata unavailable; rendering status without metadata"
);
None
}
Err(err) => {
tracing::warn!(
target: "codex_tui::status_metadata",
"account/metadata/read failed before /status render: {err}"
);
None
}
};
self.chat_widget
.finish_status_metadata_fetch(request_id, account_metadata);
}
fn fetch_plugins_list(&mut self, app_server: &AppServerSession, cwd: PathBuf) {
let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
@@ -4577,6 +4657,12 @@ impl App {
AppEvent::RefreshRateLimits { origin } => {
self.refresh_rate_limits(app_server, origin);
}
AppEvent::RefreshStatusMetadata { request_id } => {
self.refresh_status_metadata(app_server, request_id);
}
AppEvent::StatusMetadataLoaded { request_id, result } => {
self.on_status_metadata_loaded(request_id, result);
}
AppEvent::RateLimitsLoaded { origin, result } => match result {
Ok(snapshots) => {
for snapshot in snapshots {
@@ -6229,6 +6315,40 @@ async fn fetch_account_rate_limits(
Ok(app_server_rate_limit_snapshots_to_core(response))
}
async fn fetch_account_metadata(
request_handle: AppServerRequestHandle,
) -> Result<GetAccountMetadataResponse> {
let request_id = RequestId::String(format!("account-metadata-{}", Uuid::new_v4()));
let response: GetAccountMetadataResponse = request_handle
.request_typed(ClientRequest::GetAccountMetadata {
request_id,
params: GetAccountMetadataParams {
eligibility_only: false,
},
})
.await
.wrap_err("account/metadata/read failed in TUI")?;
Ok(response)
}
async fn fetch_account_metadata_eligibility(
request_handle: AppServerRequestHandle,
) -> Result<bool> {
let request_id = RequestId::String(format!("account-metadata-eligibility-{}", Uuid::new_v4()));
let response: GetAccountMetadataResponse = request_handle
.request_typed(ClientRequest::GetAccountMetadata {
request_id,
params: GetAccountMetadataParams {
eligibility_only: true,
},
})
.await
.wrap_err("account/metadata/read eligibility check failed in TUI")?;
Ok(response.metadata_fetch_eligible)
}
async fn fetch_plugins_list(
request_handle: AppServerRequestHandle,
cwd: PathBuf,
+12
View File
@@ -10,6 +10,7 @@
use std::path::PathBuf;
use codex_app_server_protocol::GetAccountMetadataResponse;
use codex_app_server_protocol::McpServerStatus;
use codex_app_server_protocol::PluginInstallResponse;
use codex_app_server_protocol::PluginListResponse;
@@ -171,6 +172,17 @@ pub(crate) enum AppEvent {
result: Result<Vec<RateLimitSnapshot>, String>,
},
/// Refresh ChatGPT account metadata for a `/status` output.
RefreshStatusMetadata {
request_id: u64,
},
/// Result of loading ChatGPT account metadata for `/status`.
StatusMetadataLoaded {
request_id: u64,
result: Result<Option<GetAccountMetadataResponse>, String>,
},
/// Result of prefetching connectors.
ConnectorsLoaded {
result: Result<ConnectorsSnapshot, String>,
+75 -17
View File
@@ -69,6 +69,7 @@ use crate::model_catalog::ModelCatalog;
use crate::multi_agents;
use crate::status::RateLimitWindowDisplay;
use crate::status::StatusAccountDisplay;
use crate::status::StatusAccountMetadata;
use crate::status::StatusHistoryHandle;
use crate::status::format_directory_display;
use crate::status::format_tokens_compact;
@@ -768,7 +769,9 @@ pub(crate) struct ChatWidget {
token_info: Option<TokenUsageInfo>,
rate_limit_snapshots_by_limit_id: BTreeMap<String, RateLimitSnapshotDisplay>,
refreshing_status_outputs: Vec<(u64, StatusHistoryHandle)>,
loading_status_metadata_outputs: Vec<(u64, StatusHistoryHandle)>,
next_status_refresh_request_id: u64,
next_status_metadata_request_id: u64,
plan_type: Option<PlanType>,
rate_limit_warnings: RateLimitWarningState,
rate_limit_switch_prompt: RateLimitSwitchPromptState,
@@ -4801,7 +4804,9 @@ impl ChatWidget {
token_info: None,
rate_limit_snapshots_by_limit_id: BTreeMap::new(),
refreshing_status_outputs: Vec::new(),
loading_status_metadata_outputs: Vec::new(),
next_status_refresh_request_id: 0,
next_status_metadata_request_id: 0,
plan_type: initial_plan_type,
rate_limit_warnings: RateLimitWarningState::default(),
rate_limit_switch_prompt: RateLimitSwitchPromptState::default(),
@@ -7065,7 +7070,9 @@ impl ChatWidget {
pub(crate) fn add_status_output(
&mut self,
refreshing_rate_limits: bool,
request_id: Option<u64>,
rate_limit_request_id: Option<u64>,
metadata_request_id: Option<u64>,
account_metadata: Option<StatusAccountMetadata>,
) {
let default_usage = TokenUsage::default();
let token_info = self.token_info.as_ref();
@@ -7081,6 +7088,7 @@ impl ChatWidget {
.collect();
let agents_summary =
crate::status::compose_agents_summary(&self.config, &self.instruction_source_paths);
let loading_account_metadata = metadata_request_id.is_some();
let (cell, handle) = crate::status::new_status_output_with_rate_limits_handle(
&self.config,
self.status_account_display.as_ref(),
@@ -7097,35 +7105,85 @@ impl ChatWidget {
reasoning_effort_override,
agents_summary,
refreshing_rate_limits,
account_metadata,
loading_account_metadata,
);
if let Some(request_id) = request_id {
self.refreshing_status_outputs.push((request_id, handle));
if let Some(request_id) = rate_limit_request_id {
self.refreshing_status_outputs
.push((request_id, handle.clone()));
}
self.add_to_history(cell);
if let Some(request_id) = metadata_request_id {
self.loading_status_metadata_outputs
.push((request_id, handle.clone()));
}
if loading_account_metadata {
self.flush_answer_stream_with_separator();
self.flush_active_cell();
self.active_cell = Some(Box::new(cell));
self.bump_active_cell_revision();
self.request_redraw();
} else {
self.add_to_history(cell);
}
}
fn finish_pending_status_output(
pending_outputs: &mut Vec<(u64, StatusHistoryHandle)>,
request_id: u64,
finish: impl FnOnce(&StatusHistoryHandle),
) -> bool {
if pending_outputs.is_empty() {
return false;
}
let mut remaining = Vec::with_capacity(pending_outputs.len());
let mut finish = Some(finish);
let mut updated_any = false;
for (pending_request_id, handle) in pending_outputs.drain(..) {
if pending_request_id == request_id {
updated_any = true;
if let Some(finish) = finish.take() {
finish(&handle);
}
} else {
remaining.push((pending_request_id, handle));
}
}
*pending_outputs = remaining;
updated_any
}
pub(crate) fn finish_status_rate_limit_refresh(&mut self, request_id: u64) {
if self.refreshing_status_outputs.is_empty() {
return;
}
let rate_limit_snapshots: Vec<RateLimitSnapshotDisplay> = self
.rate_limit_snapshots_by_limit_id
.values()
.cloned()
.collect();
let now = Local::now();
let mut remaining = Vec::with_capacity(self.refreshing_status_outputs.len());
let mut updated_any = false;
for (pending_request_id, handle) in self.refreshing_status_outputs.drain(..) {
if pending_request_id == request_id {
updated_any = true;
let updated_any = Self::finish_pending_status_output(
&mut self.refreshing_status_outputs,
request_id,
|handle| {
handle.finish_rate_limit_refresh(rate_limit_snapshots.as_slice(), now);
} else {
remaining.push((pending_request_id, handle));
}
},
);
if updated_any {
self.request_redraw();
}
self.refreshing_status_outputs = remaining;
}
pub(crate) fn finish_status_metadata_fetch(
&mut self,
request_id: u64,
account_metadata: Option<StatusAccountMetadata>,
) {
let updated_any = Self::finish_pending_status_output(
&mut self.loading_status_metadata_outputs,
request_id,
|handle| {
handle.finish_account_metadata_fetch(account_metadata);
},
);
if updated_any {
self.request_redraw();
}
+21 -2
View File
@@ -270,19 +270,38 @@ impl ChatWidget {
self.open_skills_menu();
}
SlashCommand::Status => {
let metadata_request_id = self.has_chatgpt_account().then(|| {
let request_id = self.next_status_metadata_request_id;
self.next_status_metadata_request_id =
self.next_status_metadata_request_id.wrapping_add(1);
request_id
});
if self.should_prefetch_rate_limits() {
let request_id = self.next_status_refresh_request_id;
self.next_status_refresh_request_id =
self.next_status_refresh_request_id.wrapping_add(1);
self.add_status_output(/*refreshing_rate_limits*/ true, Some(request_id));
self.add_status_output(
/*refreshing_rate_limits*/ true,
Some(request_id),
metadata_request_id,
/*account_metadata*/ None,
);
self.app_event_tx.send(AppEvent::RefreshRateLimits {
origin: RateLimitRefreshOrigin::StatusCommand { request_id },
});
} else {
self.add_status_output(
/*refreshing_rate_limits*/ false, /*request_id*/ None,
/*refreshing_rate_limits*/ false,
/*rate_limit_request_id*/ None,
metadata_request_id,
/*account_metadata*/ None,
);
}
if let Some(request_id) = metadata_request_id {
self.app_event_tx
.send(AppEvent::RefreshStatusMetadata { request_id });
}
}
SlashCommand::DebugConfig => {
self.add_debug_config_output();
@@ -197,7 +197,9 @@ pub(super) async fn make_chatwidget_manual(
token_info: None,
rate_limit_snapshots_by_limit_id: BTreeMap::new(),
refreshing_status_outputs: Vec::new(),
loading_status_metadata_outputs: Vec::new(),
next_status_refresh_request_id: 0,
next_status_metadata_request_id: 0,
plan_type: None,
rate_limit_warnings: RateLimitWarningState::default(),
rate_limit_switch_prompt: RateLimitSwitchPromptState::default(),
@@ -86,7 +86,8 @@ async fn turn_started_uses_runtime_context_window_before_first_token_count() {
assert_eq!(chat.bottom_pane.context_window_percent(), Some(100));
chat.add_status_output(
/*refreshing_rate_limits*/ false, /*request_id*/ None,
/*refreshing_rate_limits*/ false, /*rate_limit_request_id*/ None,
/*metadata_request_id*/ None, /*account_metadata*/ None,
);
let cells = drain_insert_history(&mut rx);
@@ -1,12 +1,34 @@
use super::*;
use crate::app_event::RateLimitRefreshOrigin;
use assert_matches::assert_matches;
fn render_status_without_metadata(chat: &mut ChatWidget) {
if chat.should_prefetch_rate_limits() {
let request_id = chat.next_status_refresh_request_id;
chat.next_status_refresh_request_id = chat.next_status_refresh_request_id.wrapping_add(1);
chat.add_status_output(
/*refreshing_rate_limits*/ true,
Some(request_id),
/*metadata_request_id*/ None,
/*account_metadata*/ None,
);
chat.app_event_tx.send(AppEvent::RefreshRateLimits {
origin: RateLimitRefreshOrigin::StatusCommand { request_id },
});
} else {
chat.add_status_output(
/*refreshing_rate_limits*/ false, /*rate_limit_request_id*/ None,
/*metadata_request_id*/ None, /*account_metadata*/ None,
);
}
}
#[tokio::test]
async fn status_command_renders_immediately_and_refreshes_rate_limits_for_chatgpt_auth() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
chat.dispatch_command(SlashCommand::Status);
render_status_without_metadata(&mut chat);
let rendered = match rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => {
@@ -32,7 +54,7 @@ async fn status_command_refresh_updates_cached_limits_for_future_status_outputs(
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
chat.dispatch_command(SlashCommand::Status);
render_status_without_metadata(&mut chat);
match rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(_)) => {}
@@ -49,7 +71,7 @@ async fn status_command_refresh_updates_cached_limits_for_future_status_outputs(
chat.finish_status_rate_limit_refresh(first_request_id);
drain_insert_history(&mut rx);
chat.dispatch_command(SlashCommand::Status);
render_status_without_metadata(&mut chat);
let refreshed = match rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => {
lines_to_single_string(&cell.display_lines(/*width*/ 80))
@@ -66,7 +88,7 @@ async fn status_command_refresh_updates_cached_limits_for_future_status_outputs(
async fn status_command_renders_immediately_without_rate_limit_refresh() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.dispatch_command(SlashCommand::Status);
render_status_without_metadata(&mut chat);
assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_)));
assert!(
@@ -81,7 +103,7 @@ async fn status_command_renders_instruction_sources_from_thread_session() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.instruction_source_paths = vec![chat.config.cwd.join("AGENTS.md")];
chat.dispatch_command(SlashCommand::Status);
render_status_without_metadata(&mut chat);
let rendered = match rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => {
@@ -104,7 +126,7 @@ async fn status_command_overlapping_refreshes_update_matching_cells_only() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
chat.dispatch_command(SlashCommand::Status);
render_status_without_metadata(&mut chat);
match rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(_)) => {}
other => panic!("expected first status output, got {other:?}"),
@@ -116,7 +138,7 @@ async fn status_command_overlapping_refreshes_update_matching_cells_only() {
other => panic!("expected first refresh request, got {other:?}"),
};
chat.dispatch_command(SlashCommand::Status);
render_status_without_metadata(&mut chat);
let second_rendered = match rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => {
lines_to_single_string(&cell.display_lines(/*width*/ 80))
+6
View File
@@ -6,3 +6,9 @@ pub(crate) enum StatusAccountDisplay {
},
ApiKey,
}
#[derive(Debug, Clone, Default)]
pub(crate) struct StatusAccountMetadata {
pub(crate) account_display_name: Option<String>,
pub(crate) account_group_names: Option<Vec<String>>,
}
+79 -1
View File
@@ -23,6 +23,7 @@ use std::path::PathBuf;
use url::Url;
use super::account::StatusAccountDisplay;
use super::account::StatusAccountMetadata;
use super::format::FieldFormatter;
use super::format::line_display_width;
use super::format::push_label;
@@ -65,9 +66,16 @@ struct StatusRateLimitState {
refreshing_rate_limits: bool,
}
#[derive(Debug, Clone)]
struct StatusAccountMetadataState {
account_metadata: Option<StatusAccountMetadata>,
loading_account_metadata: bool,
}
#[derive(Debug, Clone)]
pub(crate) struct StatusHistoryHandle {
rate_limit_state: Arc<RwLock<StatusRateLimitState>>,
account_metadata_state: Arc<RwLock<StatusAccountMetadataState>>,
}
impl StatusHistoryHandle {
@@ -89,6 +97,19 @@ impl StatusHistoryHandle {
state.rate_limits = rate_limits;
state.refreshing_rate_limits = false;
}
pub(crate) fn finish_account_metadata_fetch(
&self,
account_metadata: Option<StatusAccountMetadata>,
) {
#[expect(clippy::expect_used)]
let mut state = self
.account_metadata_state
.write()
.expect("status history account metadata state poisoned");
state.account_metadata = account_metadata;
state.loading_account_metadata = false;
}
}
#[derive(Debug)]
@@ -101,6 +122,7 @@ struct StatusHistoryCell {
collaboration_mode: Option<String>,
model_provider: Option<String>,
account: Option<StatusAccountDisplay>,
account_metadata_state: Arc<RwLock<StatusAccountMetadataState>>,
thread_name: Option<String>,
session_id: Option<String>,
forked_from: Option<String>,
@@ -178,6 +200,8 @@ pub(crate) fn new_status_output_with_rate_limits(
reasoning_effort_override,
"<none>".to_string(),
refreshing_rate_limits,
/*account_metadata*/ None,
/*loading_account_metadata*/ false,
)
.0
}
@@ -199,6 +223,8 @@ pub(crate) fn new_status_output_with_rate_limits_handle(
reasoning_effort_override: Option<Option<ReasoningEffort>>,
agents_summary: String,
refreshing_rate_limits: bool,
account_metadata: Option<StatusAccountMetadata>,
loading_account_metadata: bool,
) -> (CompositeHistoryCell, StatusHistoryHandle) {
let command = PlainHistoryCell::new(vec!["/status".magenta().into()]);
let (card, handle) = StatusHistoryCell::new(
@@ -217,6 +243,8 @@ pub(crate) fn new_status_output_with_rate_limits_handle(
reasoning_effort_override,
agents_summary,
refreshing_rate_limits,
account_metadata,
loading_account_metadata,
);
(
@@ -243,6 +271,8 @@ impl StatusHistoryCell {
reasoning_effort_override: Option<Option<ReasoningEffort>>,
agents_summary: String,
refreshing_rate_limits: bool,
account_metadata: Option<StatusAccountMetadata>,
loading_account_metadata: bool,
) -> (Self, StatusHistoryHandle) {
let mut config_entries = vec![
("workdir", config.cwd.display().to_string()),
@@ -335,6 +365,10 @@ impl StatusHistoryCell {
rate_limits,
refreshing_rate_limits,
}));
let account_metadata_state = Arc::new(RwLock::new(StatusAccountMetadataState {
account_metadata,
loading_account_metadata,
}));
let agents_summary = Arc::new(RwLock::new(agents_summary));
(
@@ -346,6 +380,7 @@ impl StatusHistoryCell {
collaboration_mode: collaboration_mode.map(ToString::to_string),
model_provider,
account,
account_metadata_state: account_metadata_state.clone(),
thread_name,
session_id,
forked_from,
@@ -353,7 +388,10 @@ impl StatusHistoryCell {
agents_summary,
rate_limit_state: rate_limit_state.clone(),
},
StatusHistoryHandle { rate_limit_state },
StatusHistoryHandle {
rate_limit_state,
account_metadata_state,
},
)
}
@@ -592,6 +630,13 @@ impl HistoryCell for StatusHistoryCell {
.read()
.expect("status history agents summary state poisoned")
.clone();
#[expect(clippy::expect_used)]
let account_metadata_state = self
.account_metadata_state
.read()
.expect("status history account metadata state poisoned")
.clone();
let account_metadata = account_metadata_state.account_metadata.clone();
if self.model_provider.is_some() {
push_label(&mut labels, &mut seen, "Model provider");
@@ -599,6 +644,22 @@ impl HistoryCell for StatusHistoryCell {
if account_value.is_some() {
push_label(&mut labels, &mut seen, "Account");
}
if account_metadata_state.loading_account_metadata
|| account_metadata
.as_ref()
.and_then(|metadata| metadata.account_display_name.as_ref())
.is_some()
{
push_label(&mut labels, &mut seen, "Organization");
}
if account_metadata_state.loading_account_metadata
|| account_metadata
.as_ref()
.and_then(|metadata| metadata.account_group_names.as_ref())
.is_some_and(|group_names| !group_names.is_empty())
{
push_label(&mut labels, &mut seen, "Groups");
}
if thread_name.is_some() {
push_label(&mut labels, &mut seen, "Thread name");
}
@@ -658,6 +719,23 @@ impl HistoryCell for StatusHistoryCell {
if let Some(account_value) = account_value {
lines.push(formatter.line("Account", vec![Span::from(account_value)]));
}
if account_metadata_state.loading_account_metadata {
lines.push(formatter.line(
"Organization",
vec![Span::from("Retrieving account information...").dim()],
));
} else if let Some(metadata) = account_metadata {
if let Some(account_display_name) = metadata.account_display_name {
lines.push(formatter.line("Organization", vec![Span::from(account_display_name)]));
}
if let Some(account_group_names) = metadata.account_group_names
&& !account_group_names.is_empty()
{
lines.push(
formatter.line("Groups", vec![Span::from(account_group_names.join(", "))]),
);
}
}
if let Some(thread_name) = thread_name {
lines.push(formatter.line("Thread name", vec![Span::from(thread_name.to_string())]));
+1
View File
@@ -13,6 +13,7 @@ mod helpers;
mod rate_limits;
pub(crate) use account::StatusAccountDisplay;
pub(crate) use account::StatusAccountMetadata;
pub(crate) use card::StatusHistoryHandle;
#[cfg(test)]
pub(crate) use card::new_status_output;
@@ -0,0 +1,22 @@
---
source: tui/src/status/tests.rs
expression: sanitized
---
/status
╭─────────────────────────────────────────────────────────────────────╮
│ >_ OpenAI Codex (v0.0.0) │
│ │
│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │
│ information on rate limits and credits │
│ │
│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │
│ Directory: [[workspace]] │
│ Permissions: Custom (read-only, on-request) │
│ Agents.md: <none> │
│ Account: dev@example.com (Business) │
│ Organization: Acme Corp │
│ Groups: Engineering, Codex Pilots │
│ │
│ Limits: data not available yet │
╰─────────────────────────────────────────────────────────────────────╯
+54
View File
@@ -1,10 +1,12 @@
use super::new_status_output;
use super::new_status_output_with_rate_limits;
use super::new_status_output_with_rate_limits_handle;
use super::rate_limit_snapshot_display;
use crate::history_cell::HistoryCell;
use crate::legacy_core::config::Config;
use crate::legacy_core::config::ConfigBuilder;
use crate::status::StatusAccountDisplay;
use crate::status::StatusAccountMetadata;
use crate::test_support::PathBufExt;
use crate::test_support::test_path_buf;
use chrono::Duration as ChronoDuration;
@@ -289,6 +291,58 @@ async fn status_snapshot_includes_forked_from() {
assert_snapshot!(sanitized);
}
#[tokio::test]
async fn status_snapshot_includes_account_metadata() {
let temp_home = TempDir::new().expect("temp home");
let mut config = test_config(&temp_home).await;
config.model = Some("gpt-5.1-codex-max".to_string());
config.model_provider_id = "openai".to_string();
config.cwd = test_path_buf("/workspace/tests").abs();
let account_display = Some(StatusAccountDisplay::ChatGpt {
email: Some("dev@example.com".to_string()),
plan: Some("Business".to_string()),
});
let account_metadata = StatusAccountMetadata {
account_display_name: Some("Acme Corp".to_string()),
account_group_names: Some(vec!["Engineering".to_string(), "Codex Pilots".to_string()]),
};
let usage = TokenUsage::default();
let captured_at = chrono::Local
.with_ymd_and_hms(2024, 8, 9, 10, 11, 12)
.single()
.expect("valid time");
let model_slug = crate::legacy_core::test_support::get_model_offline(config.model.as_deref());
let (composite, _) = new_status_output_with_rate_limits_handle(
&config,
account_display.as_ref(),
/*token_info*/ None,
&usage,
&None,
/*thread_name*/ None,
/*forked_from*/ None,
/*rate_limits*/ &[],
None,
captured_at,
&model_slug,
/*collaboration_mode*/ None,
/*reasoning_effort_override*/ None,
"<none>".to_string(),
/*refreshing_rate_limits*/ false,
Some(account_metadata),
/*loading_account_metadata*/ false,
);
let mut rendered_lines = render_lines(&composite.display_lines(/*width*/ 80));
if cfg!(windows) {
for line in &mut rendered_lines {
*line = line.replace('\\', "/");
}
}
let sanitized = sanitize_directory(rendered_lines).join("\n");
assert_snapshot!(sanitized);
}
#[tokio::test]
async fn status_snapshot_includes_monthly_limit() {
let temp_home = TempDir::new().expect("temp home");