mirror of
https://github.com/openai/codex.git
synced 2026-10-24 18:18:41 +00:00
Add organization and group name(s) to /status cell
This commit is contained in:
@@ -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/")]
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,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>>,
|
||||
}
|
||||
|
||||
@@ -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())]));
|
||||
|
||||
@@ -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;
|
||||
|
||||
+22
@@ -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 │
|
||||
╰─────────────────────────────────────────────────────────────────────╯
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user