diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index f9907887a4..42df7b0557 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -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": [ diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 7303eba621..cc51aece19 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -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": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 29ccdf0dd8..49b9eaeae9 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -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": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/GetAccountTokenUsageResponse.json b/codex-rs/app-server-protocol/schema/json/v2/GetAccountTokenUsageResponse.json index 557a297ce1..a7e85aa8b4 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/GetAccountTokenUsageResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/GetAccountTokenUsageResponse.json @@ -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": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/NullableGetAccountTokenUsageParams.json b/codex-rs/app-server-protocol/schema/json/v2/NullableGetAccountTokenUsageParams.json new file mode 100644 index 0000000000..b06af5fe52 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/NullableGetAccountTokenUsageParams.json @@ -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" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst index 12a055d2e4..2a1084e87d 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index 929b9b875e..a92dff8820 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index d746912e7e..dffd07fbf9 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -35,6 +35,7 @@ import type { FsUnwatchParams } from "./v2/FsUnwatchParams"; import type { FsWatchParams } from "./v2/FsWatchParams"; import type { FsWriteFileParams } from "./v2/FsWriteFileParams"; import type { GetAccountParams } from "./v2/GetAccountParams"; +import type { GetAccountTokenUsageParams } from "./v2/GetAccountTokenUsageParams"; import type { HooksListParams } from "./v2/HooksListParams"; import type { ListMcpServerStatusParams } from "./v2/ListMcpServerStatusParams"; import type { LoginAccountParams } from "./v2/LoginAccountParams"; @@ -96,4 +97,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/delete", id: RequestId, params: ThreadDeleteParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/goal/set", id: RequestId, params: ThreadGoalSetParams, } | { "method": "thread/goal/get", id: RequestId, params: ThreadGoalGetParams, } | { "method": "thread/goal/clear", id: RequestId, params: ThreadGoalClearParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/section/move", id: RequestId, params: ThreadSectionMoveParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "threadSection/list", id: RequestId, params: ThreadSectionListParams, } | { "method": "threadSection/create", id: RequestId, params: ThreadSectionCreateParams, } | { "method": "threadSection/update", id: RequestId, params: ThreadSectionUpdateParams, } | { "method": "threadSection/delete", id: RequestId, params: ThreadSectionDeleteParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "skills/extraRoots/set", id: RequestId, params: SkillsExtraRootsSetParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/installed", id: RequestId, params: PluginInstalledParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/checkout", id: RequestId, params: PluginShareCheckoutParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/read", id: RequestId, params: AppsReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "app/installed", id: RequestId, params: AppsInstalledParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "permissionProfile/list", id: RequestId, params: PermissionProfileListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/rateLimitResetCredit/consume", id: RequestId, params: ConsumeAccountRateLimitResetCreditParams, } | { "method": "account/usage/read", id: RequestId, params: undefined, } | { "method": "account/workspaceMessages/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "externalAgentConfig/import/recordHistory", id: RequestId, params: ExternalAgentConfigImportHistoryRecordParams, } | { "method": "externalAgentConfig/import/readHistories", id: RequestId, params: undefined, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/delete", id: RequestId, params: ThreadDeleteParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/goal/set", id: RequestId, params: ThreadGoalSetParams, } | { "method": "thread/goal/get", id: RequestId, params: ThreadGoalGetParams, } | { "method": "thread/goal/clear", id: RequestId, params: ThreadGoalClearParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/section/move", id: RequestId, params: ThreadSectionMoveParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "threadSection/list", id: RequestId, params: ThreadSectionListParams, } | { "method": "threadSection/create", id: RequestId, params: ThreadSectionCreateParams, } | { "method": "threadSection/update", id: RequestId, params: ThreadSectionUpdateParams, } | { "method": "threadSection/delete", id: RequestId, params: ThreadSectionDeleteParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "skills/extraRoots/set", id: RequestId, params: SkillsExtraRootsSetParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/installed", id: RequestId, params: PluginInstalledParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/checkout", id: RequestId, params: PluginShareCheckoutParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/read", id: RequestId, params: AppsReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "app/installed", id: RequestId, params: AppsInstalledParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "permissionProfile/list", id: RequestId, params: PermissionProfileListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/rateLimitResetCredit/consume", id: RequestId, params: ConsumeAccountRateLimitResetCreditParams, } | { "method": "account/usage/read", id: RequestId, params?: GetAccountTokenUsageParams | undefined, } | { "method": "account/workspaceMessages/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "externalAgentConfig/import/recordHistory", id: RequestId, params: ExternalAgentConfigImportHistoryRecordParams, } | { "method": "externalAgentConfig/import/readHistories", id: RequestId, params: undefined, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/GetAccountTokenUsageParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/GetAccountTokenUsageParams.ts new file mode 100644 index 0000000000..62e27ba4fd --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/GetAccountTokenUsageParams.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/GetAccountTokenUsageResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/GetAccountTokenUsageResponse.ts index 175c915272..f7b5f24a22 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/GetAccountTokenUsageResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/GetAccountTokenUsageResponse.ts @@ -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 | null, }; +export type GetAccountTokenUsageResponse = { summary: AccountTokenUsageSummary, dailyUsageBuckets: Array | null, +/** + * Estimated usage when a thread was requested and its billing route is available. + */ +threadUsage?: ThreadUsage | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadUsage.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadUsage.ts new file mode 100644 index 0000000000..1fcf444131 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadUsage.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadUsageBreakdownGroup.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadUsageBreakdownGroup.ts new file mode 100644 index 0000000000..e1e959367a --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadUsageBreakdownGroup.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index b56a2df9c3..017c21615b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -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"; diff --git a/codex-rs/app-server-protocol/src/export.rs b/codex-rs/app-server-protocol/src/export.rs index 0889eb82b1..a31f183563 100644 --- a/codex-rs/app-server-protocol/src/export.rs +++ b/codex-rs/app-server-protocol/src/export.rs @@ -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; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index ae5c737899..6d0866a863 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -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", 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 { diff --git a/codex-rs/app-server-protocol/src/protocol/v2/account.rs b/codex-rs/app-server-protocol/src/protocol/v2/account.rs index bee574e520..d42abf1de5 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/account.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/account.rs @@ -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, +} + +pub type NullableGetAccountTokenUsageParams = Option; + #[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>, + /// Estimated usage when a thread was requested and its billing route is available. + #[serde(default)] + #[ts(optional, as = "Option>")] + pub thread_usage: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/mod.rs b/codex-rs/app-server-protocol/src/protocol/v2/mod.rs index 1b2ee6cd15..58383789d8 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/mod.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/mod.rs @@ -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::*; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread_usage.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread_usage.rs new file mode 100644 index 0000000000..249cfa969b --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread_usage.rs @@ -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, + pub groups: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadUsageBreakdownGroup { + pub model: Option, + pub reasoning_effort: Option, + pub speed: Option, + pub estimated_usage_credits_micros: i64, + pub net_new_input_tokens: Option, + pub cached_input_tokens: Option, + pub input_tokens: Option, + pub output_tokens: Option, + pub total_tokens: Option, +} diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index d3467a581c..8135a5cad1 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -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. diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 4933ba0363..a9e57a3e3e 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -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 diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 20037bfe60..f269679803 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -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; diff --git a/codex-rs/app-server/src/request_processors/account_processor.rs b/codex-rs/app-server/src/request_processors/account_processor.rs index 1f3d45a545..3224e57fe0 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -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, ) -> Result, 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, ) -> Result { + 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, } ); } diff --git a/codex-rs/app-server/tests/suite/v2/account_thread_usage.rs b/codex-rs/app-server/tests/suite/v2/account_thread_usage.rs new file mode 100644 index 0000000000..8fd846e4d4 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/account_thread_usage.rs @@ -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(()) +} diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index a40544cd58..6fa4a33797 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -1,4 +1,5 @@ mod account; +mod account_thread_usage; mod analytics; mod app_installed; mod app_list; diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 72c43cd110..b801d06496 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -530,6 +530,9 @@ pub(crate) struct App { pub(crate) file_search: FileSearchManager, pub(crate) transcript_cells: Vec>, + last_rendered_history_tail: Option, + last_thread_usage_status_cell: Option, + pub(crate) pending_thread_usage_history_refresh: bool, // Pager overlay state (Transcript or Static like Diff) pub(crate) overlay: Option, @@ -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, diff --git a/codex-rs/tui/src/app/app_server_events.rs b/codex-rs/tui/src/app/app_server_events.rs index 72c9de441f..4877db289e 100644 --- a/codex-rs/tui/src/app/app_server_events.rs +++ b/codex-rs/tui/src/app/app_server_events.rs @@ -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( diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index 72a7df5ccf..8b1c39f023 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -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 { + 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) - 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") } diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 49049aeada..ea83ddb4f3 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -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); } diff --git a/codex-rs/tui/src/app/history_ui.rs b/codex-rs/tui/src/app/history_ui.rs index 7c56bcc5c0..024288de54 100644 --- a/codex-rs/tui/src/app/history_ui.rs +++ b/codex-rs/tui/src/app/history_ui.rs @@ -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, + pub(super) lines: Vec, +} + +pub(super) struct ThreadUsageStatusHistory { + thread_id: ThreadId, + pub(super) cell: Weak, + pub(super) lines: Vec, +} + impl App { pub(super) fn insert_history_cell(&mut self, tui: &mut tui::Tui, cell: Box) { let cell: Arc = 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::() + && 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, + ) -> 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(); diff --git a/codex-rs/tui/src/app/resize_reflow.rs b/codex-rs/tui/src/app/resize_reflow.rs index 5c9eaea8e9..9de5ebc0c3 100644 --- a/codex-rs/tui/src/app/resize_reflow.rs +++ b/codex-rs/tui/src/app/resize_reflow.rs @@ -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) diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index 9d80356d8b..c61caf53c6 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -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, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index ca0bed69e8..136127513b 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -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, diff --git a/codex-rs/tui/src/app/tests/thread_usage.rs b/codex-rs/tui/src/app/tests/thread_usage.rs new file mode 100644 index 0000000000..a3fdbe2770 --- /dev/null +++ b/codex-rs/tui/src/app/tests/thread_usage.rs @@ -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::() + }) + .collect::>() + .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(()) +} diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 6eebdf2bea..855e7c51c6 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -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 { diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 0d07498516..eb65ba6fd4 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -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, }, + /// 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, + }, + /// Fetch workspace messages for the status-line headline item. RefreshStatusLineWorkspaceHeadline { request_id: u64, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index e10c375d8e..577fee6093 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -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, // 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, current_goal_status: Option, diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index ebb0f4e1e4..5198cc13cc 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -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, diff --git a/codex-rs/tui/src/chatwidget/session_flow.rs b/codex-rs/tui/src/chatwidget/session_flow.rs index 52218e022a..00ba854a85 100644 --- a/codex-rs/tui/src/chatwidget/session_flow.rs +++ b/codex-rs/tui/src/chatwidget/session_flow.rs @@ -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(); diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index 473bccb4cf..779c2d98a0 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -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; diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_command_estimated_thread_usage.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_command_estimated_thread_usage.snap new file mode 100644 index 0000000000..9e649f0a8c --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_command_estimated_thread_usage.snap @@ -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 │ +╰──────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_command_estimated_thread_usage_without_usd.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_command_estimated_thread_usage_without_usd.snap new file mode 100644 index 0000000000..05f781c53d --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_command_estimated_thread_usage_without_usd.snap @@ -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 │ +╰──────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/chatwidget/status_controls.rs b/codex-rs/tui/src/chatwidget/status_controls.rs index 8a78601ea7..d715be1ab5 100644 --- a/codex-rs/tui/src/chatwidget/status_controls.rs +++ b/codex-rs/tui/src/chatwidget/status_controls.rs @@ -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( diff --git a/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs b/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs index 21e02db252..25f3e83a37 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs @@ -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::>() + .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::>() + .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()); +} diff --git a/codex-rs/tui/src/chatwidget/thread_usage.rs b/codex-rs/tui/src/chatwidget/thread_usage.rs new file mode 100644 index 0000000000..c4ba3c4a8b --- /dev/null +++ b/codex-rs/tui/src/chatwidget/thread_usage.rs @@ -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, + estimate: Option, + pending_request_id: Option, + next_request_id: u64, + feature_disabled: bool, + status_history_handles: Vec, +} + +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, + ) -> 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 + ) + ) + } +} diff --git a/codex-rs/tui/src/chatwidget/tokens/chart_tests.rs b/codex-rs/tui/src/chatwidget/tokens/chart_tests.rs index 20e0b45878..9f706f7208 100644 --- a/codex-rs/tui/src/chatwidget/tokens/chart_tests.rs +++ b/codex-rs/tui/src/chatwidget/tokens/chart_tests.rs @@ -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)) diff --git a/codex-rs/tui/src/chatwidget/tokens_tests.rs b/codex-rs/tui/src/chatwidget/tokens_tests.rs index 042baa84d5..4c8d741391 100644 --- a/codex-rs/tui/src/chatwidget/tokens_tests.rs +++ b/codex-rs/tui/src/chatwidget/tokens_tests.rs @@ -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, ); diff --git a/codex-rs/tui/src/insert_history.rs b/codex-rs/tui/src/insert_history.rs index 7b9b3121da..28682b6160 100644 --- a/codex-rs/tui/src/insert_history.rs +++ b/codex-rs/tui/src/insert_history.rs @@ -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::(); - 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, 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::(); + 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 { diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index 382a9e3c6f..93f959c8db 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -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>, + 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, + ) { + self.thread_usage.set_estimate(estimate); + } } #[derive(Debug)] @@ -119,6 +132,7 @@ struct StatusHistoryCell { forked_from: Option, token_usage: StatusTokenUsageData, rate_limit_state: Arc>, + 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::>::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); diff --git a/codex-rs/tui/src/status/mod.rs b/codex-rs/tui/src/status/mod.rs index b69d6910d4..cdf89ffcaf 100644 --- a/codex-rs/tui/src/status/mod.rs +++ b/codex-rs/tui/src/status/mod.rs @@ -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; diff --git a/codex-rs/tui/src/status/thread_usage.rs b/codex-rs/tui/src/status/thread_usage.rs new file mode 100644 index 0000000000..49fd4c6c3d --- /dev/null +++ b/codex-rs/tui/src/status/thread_usage.rs @@ -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>>, + reserve_label_width: Arc, +} + +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) { + #[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, seen: &mut BTreeSet) { + // 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> { + #[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| { + 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 { + let mut totals = BTreeMap::::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::>(); + 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::>() + .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 { + 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; diff --git a/codex-rs/tui/src/status/thread_usage_tests.rs b/codex-rs/tui/src/status/thread_usage_tests.rs new file mode 100644 index 0000000000..db27fd2431 --- /dev/null +++ b/codex-rs/tui/src/status/thread_usage_tests.rs @@ -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"); +} diff --git a/codex-rs/tui/src/test_backend.rs b/codex-rs/tui/src/test_backend.rs index 8487c6a741..a71bc52650 100644 --- a/codex-rs/tui/src/test_backend.rs +++ b/codex-rs/tui/src/test_backend.rs @@ -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, + )), } } diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 6b06113ec1..6fb8f87b5a 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -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; diff --git a/codex-rs/tui/src/tui/history_tail.rs b/codex-rs/tui/src/tui/history_tail.rs new file mode 100644 index 0000000000..6b018eec84 --- /dev/null +++ b/codex-rs/tui/src/tui/history_tail.rs @@ -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 { + 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 { + 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( + terminal: &mut CustomTerminal, + previous_lines: &[HyperlinkLine], + replacement: &[HyperlinkLine], + mode: InsertHistoryMode, + wrap_policy: HistoryLineWrapPolicy, +) -> io::Result +where + B: Backend + 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; diff --git a/codex-rs/tui/src/tui/history_tail_tests.rs b/codex-rs/tui/src/tui/history_tail_tests.rs new file mode 100644 index 0000000000..4288a880c0 --- /dev/null +++ b/codex-rs/tui/src/tui/history_tail_tests.rs @@ -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}"); +}