Add workspace messages app-server API (#29001)

## Summary

- Add backend-client types and fetch support for active workspace
messages.
- Add the app-server v2 `account/workspaceMessages/read` method,
generated schemas, and README documentation.
- Delegate workspace-message eligibility to the Codex backend feature
gate; map a backend 404 to `featureEnabled: false`.

## Testing

- `just write-app-server-schema`
- `just test -p codex-backend-client`
- `just test -p codex-app-server-protocol`
- `just test -p codex-app-server workspace_messages`
- `just fix -p codex-backend-client -p codex-app-server-protocol -p
codex-app-server`
- `just fmt`

## Stack

- Base PR for #28232, which adds the TUI status-line integration.
This commit is contained in:
xli-oai authored and GitHub committed 2026-06-22 04:25:07 -07:00
1 parent 9c3b10e5d4
commit 21d36296f1
34 files changed
+1163 -3

No files matched your search

@@ -6462,6 +6462,29 @@
"title": "Account/usage/readRequest", "title": "Account/usage/readRequest",
"type": "object" "type": "object"
}, },
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"account/workspaceMessages/read"
],
"title": "Account/workspaceMessages/readRequestMethod",
"type": "string"
},
"params": {
"type": "null"
}
},
"required": [
"id",
"method"
],
"title": "Account/workspaceMessages/readRequest",
"type": "object"
},
{ {
"properties": { "properties": {
"id": { "id": {
@@ -1904,6 +1904,29 @@
"title": "Account/usage/readRequest", "title": "Account/usage/readRequest",
"type": "object" "type": "object"
}, },
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"account/workspaceMessages/read"
],
"title": "Account/workspaceMessages/readRequestMethod",
"type": "string"
},
"params": {
"type": "null"
}
},
"required": [
"id",
"method"
],
"title": "Account/workspaceMessages/readRequest",
"type": "object"
},
{ {
"properties": { "properties": {
"id": { "id": {
@@ -10354,6 +10377,28 @@
"title": "GetAccountTokenUsageResponse", "title": "GetAccountTokenUsageResponse",
"type": "object" "type": "object"
}, },
"GetWorkspaceMessagesResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"featureEnabled": {
"description": "Whether the workspace-message backend route is available for this client.",
"type": "boolean"
},
"messages": {
"description": "Active workspace messages returned by the backend.",
"items": {
"$ref": "#/definitions/v2/WorkspaceMessage"
},
"type": "array"
}
},
"required": [
"featureEnabled",
"messages"
],
"title": "GetWorkspaceMessagesResponse",
"type": "object"
},
"GitInfo": { "GitInfo": {
"properties": { "properties": {
"branch": { "branch": {
@@ -20564,6 +20609,49 @@
"title": "WindowsWorldWritableWarningNotification", "title": "WindowsWorldWritableWarningNotification",
"type": "object" "type": "object"
}, },
"WorkspaceMessage": {
"properties": {
"archivedAt": {
"description": "Unix timestamp (in seconds) when the message was archived.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"createdAt": {
"description": "Unix timestamp (in seconds) when the message was created.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"messageBody": {
"type": "string"
},
"messageId": {
"type": "string"
},
"messageType": {
"$ref": "#/definitions/v2/WorkspaceMessageType"
}
},
"required": [
"messageBody",
"messageId",
"messageType"
],
"type": "object"
},
"WorkspaceMessageType": {
"enum": [
"headline",
"announcement",
"unknown"
],
"type": "string"
},
"WriteStatus": { "WriteStatus": {
"enum": [ "enum": [
"ok", "ok",
@@ -2913,6 +2913,29 @@
"title": "Account/usage/readRequest", "title": "Account/usage/readRequest",
"type": "object" "type": "object"
}, },
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"account/workspaceMessages/read"
],
"title": "Account/workspaceMessages/readRequestMethod",
"type": "string"
},
"params": {
"type": "null"
}
},
"required": [
"id",
"method"
],
"title": "Account/workspaceMessages/readRequest",
"type": "object"
},
{ {
"properties": { "properties": {
"id": { "id": {
@@ -6705,6 +6728,28 @@
"title": "GetAccountTokenUsageResponse", "title": "GetAccountTokenUsageResponse",
"type": "object" "type": "object"
}, },
"GetWorkspaceMessagesResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"featureEnabled": {
"description": "Whether the workspace-message backend route is available for this client.",
"type": "boolean"
},
"messages": {
"description": "Active workspace messages returned by the backend.",
"items": {
"$ref": "#/definitions/WorkspaceMessage"
},
"type": "array"
}
},
"required": [
"featureEnabled",
"messages"
],
"title": "GetWorkspaceMessagesResponse",
"type": "object"
},
"GitInfo": { "GitInfo": {
"properties": { "properties": {
"branch": { "branch": {
@@ -18343,6 +18388,49 @@
"title": "WindowsWorldWritableWarningNotification", "title": "WindowsWorldWritableWarningNotification",
"type": "object" "type": "object"
}, },
"WorkspaceMessage": {
"properties": {
"archivedAt": {
"description": "Unix timestamp (in seconds) when the message was archived.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"createdAt": {
"description": "Unix timestamp (in seconds) when the message was created.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"messageBody": {
"type": "string"
},
"messageId": {
"type": "string"
},
"messageType": {
"$ref": "#/definitions/WorkspaceMessageType"
}
},
"required": [
"messageBody",
"messageId",
"messageType"
],
"type": "object"
},
"WorkspaceMessageType": {
"enum": [
"headline",
"announcement",
"unknown"
],
"type": "string"
},
"WriteStatus": { "WriteStatus": {
"enum": [ "enum": [
"ok", "ok",
@@ -0,0 +1,67 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"WorkspaceMessage": {
"properties": {
"archivedAt": {
"description": "Unix timestamp (in seconds) when the message was archived.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"createdAt": {
"description": "Unix timestamp (in seconds) when the message was created.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"messageBody": {
"type": "string"
},
"messageId": {
"type": "string"
},
"messageType": {
"$ref": "#/definitions/WorkspaceMessageType"
}
},
"required": [
"messageBody",
"messageId",
"messageType"
],
"type": "object"
},
"WorkspaceMessageType": {
"enum": [
"headline",
"announcement",
"unknown"
],
"type": "string"
}
},
"properties": {
"featureEnabled": {
"description": "Whether the workspace-message backend route is available for this client.",
"type": "boolean"
},
"messages": {
"description": "Active workspace messages returned by the backend.",
"items": {
"$ref": "#/definitions/WorkspaceMessage"
},
"type": "array"
}
},
"required": [
"featureEnabled",
"messages"
],
"title": "GetWorkspaceMessagesResponse",
"type": "object"
}
@@ -88,4 +88,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta
/** /**
* Request from the client to the server. * 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/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": "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/list", id: RequestId, params: AppsListParams, } | { "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, } Line truncated 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/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": "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/list", id: RequestId, params: AppsListParams, } | { "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, } Line truncated
@@ -0,0 +1,14 @@
// 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 { WorkspaceMessage } from "./WorkspaceMessage";
export type GetWorkspaceMessagesResponse = {
/**
* Whether the workspace-message backend route is available for this client.
*/
featureEnabled: boolean,
/**
* Active workspace messages returned by the backend.
*/
messages: Array<WorkspaceMessage>, };
@@ -0,0 +1,14 @@
// 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 { WorkspaceMessageType } from "./WorkspaceMessageType";
export type WorkspaceMessage = { messageId: string, messageType: WorkspaceMessageType, messageBody: string,
/**
* Unix timestamp (in seconds) when the message was created.
*/
createdAt: number | null,
/**
* Unix timestamp (in seconds) when the message was archived.
*/
archivedAt: number | null, };
@@ -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 WorkspaceMessageType = "headline" | "announcement" | "unknown";
@@ -157,6 +157,7 @@ export type { GetAccountParams } from "./GetAccountParams";
export type { GetAccountRateLimitsResponse } from "./GetAccountRateLimitsResponse"; export type { GetAccountRateLimitsResponse } from "./GetAccountRateLimitsResponse";
export type { GetAccountResponse } from "./GetAccountResponse"; export type { GetAccountResponse } from "./GetAccountResponse";
export type { GetAccountTokenUsageResponse } from "./GetAccountTokenUsageResponse"; export type { GetAccountTokenUsageResponse } from "./GetAccountTokenUsageResponse";
export type { GetWorkspaceMessagesResponse } from "./GetWorkspaceMessagesResponse";
export type { GitInfo } from "./GitInfo"; export type { GitInfo } from "./GitInfo";
export type { GrantedPermissionProfile } from "./GrantedPermissionProfile"; export type { GrantedPermissionProfile } from "./GrantedPermissionProfile";
export type { GuardianApprovalReview } from "./GuardianApprovalReview"; export type { GuardianApprovalReview } from "./GuardianApprovalReview";
@@ -494,4 +495,6 @@ export type { WindowsSandboxSetupMode } from "./WindowsSandboxSetupMode";
export type { WindowsSandboxSetupStartParams } from "./WindowsSandboxSetupStartParams"; export type { WindowsSandboxSetupStartParams } from "./WindowsSandboxSetupStartParams";
export type { WindowsSandboxSetupStartResponse } from "./WindowsSandboxSetupStartResponse"; export type { WindowsSandboxSetupStartResponse } from "./WindowsSandboxSetupStartResponse";
export type { WindowsWorldWritableWarningNotification } from "./WindowsWorldWritableWarningNotification"; export type { WindowsWorldWritableWarningNotification } from "./WindowsWorldWritableWarningNotification";
export type { WorkspaceMessage } from "./WorkspaceMessage";
export type { WorkspaceMessageType } from "./WorkspaceMessageType";
export type { WriteStatus } from "./WriteStatus"; export type { WriteStatus } from "./WriteStatus";
@@ -1022,6 +1022,12 @@ client_request_definitions! {
response: v2::GetAccountTokenUsageResponse, response: v2::GetAccountTokenUsageResponse,
}, },
GetWorkspaceMessages => "account/workspaceMessages/read" {
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
serialization: None,
response: v2::GetWorkspaceMessagesResponse,
},
SendAddCreditsNudgeEmail => "account/sendAddCreditsNudgeEmail" { SendAddCreditsNudgeEmail => "account/sendAddCreditsNudgeEmail" {
params: v2::SendAddCreditsNudgeEmailParams, params: v2::SendAddCreditsNudgeEmailParams,
serialization: global("account-auth"), serialization: global("account-auth"),
@@ -2541,6 +2547,24 @@ mod tests {
Ok(()) Ok(())
} }
#[test]
fn serialize_get_workspace_messages() -> Result<()> {
let request = ClientRequest::GetWorkspaceMessages {
request_id: RequestId::Integer(1),
params: None,
};
assert_eq!(request.id(), &RequestId::Integer(1));
assert_eq!(request.method(), "account/workspaceMessages/read");
assert_eq!(
json!({
"method": "account/workspaceMessages/read",
"id": 1,
}),
serde_json::to_value(&request)?,
);
Ok(())
}
#[test] #[test]
fn serialize_client_response() -> Result<()> { fn serialize_client_response() -> Result<()> {
let cwd = absolute_path("/tmp"); let cwd = absolute_path("/tmp");
@@ -314,6 +314,41 @@ pub struct GetAccountTokenUsageResponse {
pub daily_usage_buckets: Option<Vec<AccountTokenUsageDailyBucket>>, pub daily_usage_buckets: Option<Vec<AccountTokenUsageDailyBucket>>,
} }
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct GetWorkspaceMessagesResponse {
/// Whether the workspace-message backend route is available for this client.
pub feature_enabled: bool,
/// Active workspace messages returned by the backend.
pub messages: Vec<WorkspaceMessage>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct WorkspaceMessage {
pub message_id: String,
pub message_type: WorkspaceMessageType,
pub message_body: String,
/// Unix timestamp (in seconds) when the message was created.
#[ts(type = "number | null")]
pub created_at: Option<i64>,
/// Unix timestamp (in seconds) when the message was archived.
#[ts(type = "number | null")]
pub archived_at: Option<i64>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export_to = "v2/", rename_all = "snake_case")]
pub enum WorkspaceMessageType {
Headline,
Announcement,
#[serde(other)]
Unknown,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")] #[ts(export_to = "v2/")]
+13 -1
View File
@@ -1920,6 +1920,7 @@ Codex supports these authentication modes. The current mode is surfaced in `acco
- `account/rateLimits/read` — fetch ChatGPT rate limits, an optional effective monthly credit limit, and the number of earned rate-limit resets currently available. Rate-limit updates arrive via `account/rateLimits/updated` (notify); the reset count is snapshot-only. - `account/rateLimits/read` — fetch ChatGPT rate limits, an optional effective monthly credit limit, and the number of earned rate-limit resets currently available. Rate-limit updates arrive via `account/rateLimits/updated` (notify); the reset count is snapshot-only.
- `account/rateLimitResetCredit/consume` — consume one earned reset using a caller-provided idempotency key. - `account/rateLimitResetCredit/consume` — consume one earned reset using a caller-provided idempotency key.
- `account/usage/read` — fetch ChatGPT account token-activity summary and daily buckets. - `account/usage/read` — fetch ChatGPT account token-activity summary and daily buckets.
- `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. - `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.
- `account/sendAddCreditsNudgeEmail` — ask ChatGPT to email the workspace owner about depleted credits or a reached usage limit. - `account/sendAddCreditsNudgeEmail` — ask ChatGPT to email the workspace owner about depleted credits or a reached usage limit.
- `mcpServer/oauthLogin/completed` (notify) — emitted after a `mcpServer/oauth/login` flow finishes for a server; payload includes `{ name, success, error? }`. - `mcpServer/oauthLogin/completed` (notify) — emitted after a `mcpServer/oauth/login` flow finishes for a server; payload includes `{ name, success, error? }`.
@@ -2046,7 +2047,18 @@ Field notes:
- `noCredit` means the account has no earned reset credits available. - `noCredit` means the account has no earned reset credits available.
- Refetch `account/rateLimits/read` after consuming a reset instead of inferring updated windows from this response. - Refetch `account/rateLimits/read` after consuming a reset instead of inferring updated windows from this response.
### 9) Notify a workspace owner about a limit ### 9) Workspace messages (ChatGPT)
```json
{ "method": "account/workspaceMessages/read", "id": 9 }
{ "id": 9, "result": { "featureEnabled": true, "messages": [
{ "messageId": "msg_123", "messageType": "headline", "messageBody": "Workspace maintenance starts at 5pm.", "createdAt": 1781395200, "archivedAt": null }
] } }
```
When the upstream workspace-message feature is disabled, `featureEnabled` is `false` and `messages` is empty.
### 10) Notify a workspace owner about a limit
```json ```json
{ "method": "account/sendAddCreditsNudgeEmail", "id": 9, "params": { "creditType": "credits" } } { "method": "account/sendAddCreditsNudgeEmail", "id": 9, "params": { "creditType": "credits" } }
@@ -1427,6 +1427,9 @@ impl MessageProcessor {
ClientRequest::GetAccountTokenUsage { .. } => { ClientRequest::GetAccountTokenUsage { .. } => {
self.account_processor.get_account_token_usage().await self.account_processor.get_account_token_usage().await
} }
ClientRequest::GetWorkspaceMessages { .. } => {
self.account_processor.get_workspace_messages().await
}
ClientRequest::SendAddCreditsNudgeEmail { params, .. } => { ClientRequest::SendAddCreditsNudgeEmail { params, .. } => {
self.account_processor self.account_processor
.send_add_credits_nudge_email(params) .send_add_credits_nudge_email(params)
@@ -76,6 +76,7 @@ use codex_app_server_protocol::GetAuthStatusParams;
use codex_app_server_protocol::GetAuthStatusResponse; use codex_app_server_protocol::GetAuthStatusResponse;
use codex_app_server_protocol::GetConversationSummaryParams; use codex_app_server_protocol::GetConversationSummaryParams;
use codex_app_server_protocol::GetConversationSummaryResponse; use codex_app_server_protocol::GetConversationSummaryResponse;
use codex_app_server_protocol::GetWorkspaceMessagesResponse;
use codex_app_server_protocol::GitDiffToRemoteParams; use codex_app_server_protocol::GitDiffToRemoteParams;
use codex_app_server_protocol::GitDiffToRemoteResponse; use codex_app_server_protocol::GitDiffToRemoteResponse;
use codex_app_server_protocol::GitInfo as ApiGitInfo; use codex_app_server_protocol::GitInfo as ApiGitInfo;
@@ -282,10 +283,16 @@ use codex_app_server_protocol::WindowsSandboxSetupCompletedNotification;
use codex_app_server_protocol::WindowsSandboxSetupMode; use codex_app_server_protocol::WindowsSandboxSetupMode;
use codex_app_server_protocol::WindowsSandboxSetupStartParams; use codex_app_server_protocol::WindowsSandboxSetupStartParams;
use codex_app_server_protocol::WindowsSandboxSetupStartResponse; use codex_app_server_protocol::WindowsSandboxSetupStartResponse;
use codex_app_server_protocol::WorkspaceMessage;
use codex_app_server_protocol::WorkspaceMessageType;
use codex_arg0::Arg0DispatchPaths; use codex_arg0::Arg0DispatchPaths;
use codex_backend_client::AddCreditsNudgeCreditType as BackendAddCreditsNudgeCreditType; use codex_backend_client::AddCreditsNudgeCreditType as BackendAddCreditsNudgeCreditType;
use codex_backend_client::Client as BackendClient; use codex_backend_client::Client as BackendClient;
use codex_backend_client::CodexWorkspaceMessage as BackendWorkspaceMessage;
use codex_backend_client::CodexWorkspaceMessageType as BackendWorkspaceMessageType;
use codex_backend_client::CodexWorkspaceMessagesResponse as BackendWorkspaceMessagesResponse;
use codex_backend_client::ConsumeRateLimitResetCreditCode as BackendConsumeRateLimitResetCreditCode; use codex_backend_client::ConsumeRateLimitResetCreditCode as BackendConsumeRateLimitResetCreditCode;
use codex_backend_client::RequestError as BackendRequestError;
use codex_backend_client::TokenUsageProfile; use codex_backend_client::TokenUsageProfile;
use codex_chatgpt::connectors; use codex_chatgpt::connectors;
use codex_chatgpt::workspace_settings; use codex_chatgpt::workspace_settings;
@@ -1,10 +1,13 @@
use super::*; use super::*;
use chrono::DateTime;
mod rate_limit_resets; mod rate_limit_resets;
// Duration before a browser ChatGPT login attempt is abandoned. // Duration before a browser ChatGPT login attempt is abandoned.
const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60); const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60);
const ACCOUNT_TOKEN_USAGE_FETCH_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 10); const ACCOUNT_TOKEN_USAGE_FETCH_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 10);
const ACCOUNT_WORKSPACE_MESSAGES_FETCH_TIMEOUT: Duration =
Duration::from_millis(/*millis*/ 1000);
// The override is intentionally available only in debug builds, matching the login path below. // The override is intentionally available only in debug builds, matching the login path below.
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
const LOGIN_ISSUER_OVERRIDE_ENV_VAR: &str = "CODEX_APP_SERVER_LOGIN_ISSUER"; const LOGIN_ISSUER_OVERRIDE_ENV_VAR: &str = "CODEX_APP_SERVER_LOGIN_ISSUER";
@@ -142,6 +145,14 @@ impl AccountRequestProcessor {
.map(|response| Some(response.into())) .map(|response| Some(response.into()))
} }
pub(crate) async fn get_workspace_messages(
&self,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.get_workspace_messages_response()
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn send_add_credits_nudge_email( pub(crate) async fn send_add_credits_nudge_email(
&self, &self,
params: SendAddCreditsNudgeEmailParams, params: SendAddCreditsNudgeEmailParams,
@@ -943,6 +954,48 @@ impl AccountRequestProcessor {
Ok(Self::account_token_usage_response(profile)) Ok(Self::account_token_usage_response(profile))
} }
async fn get_workspace_messages_response(
&self,
) -> Result<GetWorkspaceMessagesResponse, JSONRPCErrorError> {
let Some(auth) = self.auth_manager.auth().await else {
return Err(invalid_request(
"codex account authentication required to read workspace messages",
));
};
if !auth.uses_codex_backend() {
return Err(invalid_request(
"chatgpt authentication required to read workspace messages",
));
}
let client = BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth)
.map_err(|err| internal_error(format!("failed to construct backend client: {err}")))?;
let messages = tokio::time::timeout(
ACCOUNT_WORKSPACE_MESSAGES_FETCH_TIMEOUT,
client.list_workspace_messages(),
)
.await
.map_err(|_| internal_error("workspace messages fetch timed out"))?;
match messages {
Ok(messages) => {
Self::workspace_messages_response(messages, /*feature_enabled*/ true)
}
Err(err) if workspace_messages_feature_disabled(&err) => {
Self::workspace_messages_response(
BackendWorkspaceMessagesResponse {
messages: Vec::new(),
},
/*feature_enabled*/ false,
)
}
Err(err) => Err(internal_error(format!(
"failed to fetch workspace messages: {err}"
))),
}
}
fn account_token_usage_response(profile: TokenUsageProfile) -> GetAccountTokenUsageResponse { fn account_token_usage_response(profile: TokenUsageProfile) -> GetAccountTokenUsageResponse {
let stats = profile.stats; let stats = profile.stats;
GetAccountTokenUsageResponse { GetAccountTokenUsageResponse {
@@ -965,6 +1018,20 @@ impl AccountRequestProcessor {
} }
} }
fn workspace_messages_response(
messages: BackendWorkspaceMessagesResponse,
feature_enabled: bool,
) -> Result<GetWorkspaceMessagesResponse, JSONRPCErrorError> {
Ok(GetWorkspaceMessagesResponse {
feature_enabled,
messages: messages
.messages
.into_iter()
.map(workspace_message_from_backend)
.collect::<Result<Vec<_>, _>>()?,
})
}
async fn send_add_credits_nudge_email_response( async fn send_add_credits_nudge_email_response(
&self, &self,
params: SendAddCreditsNudgeEmailParams, params: SendAddCreditsNudgeEmailParams,
@@ -1015,6 +1082,48 @@ impl AccountRequestProcessor {
} }
} }
fn workspace_message_from_backend(
message: BackendWorkspaceMessage,
) -> Result<WorkspaceMessage, JSONRPCErrorError> {
Ok(WorkspaceMessage {
message_id: message.message_id,
message_type: workspace_message_type_from_backend(message.message_type),
message_body: message.message_body,
created_at: workspace_message_timestamp_from_backend(message.created_at)?,
archived_at: workspace_message_timestamp_from_backend(message.archived_at)?,
})
}
fn workspace_message_timestamp_from_backend(
timestamp: Option<String>,
) -> Result<Option<i64>, JSONRPCErrorError> {
timestamp
.map(|timestamp| {
DateTime::parse_from_rfc3339(&timestamp)
.map(|timestamp| timestamp.timestamp())
.map_err(|err| {
internal_error(format!(
"failed to parse workspace message timestamp `{timestamp}`: {err}"
))
})
})
.transpose()
}
fn workspace_message_type_from_backend(
message_type: BackendWorkspaceMessageType,
) -> WorkspaceMessageType {
match message_type {
BackendWorkspaceMessageType::Headline => WorkspaceMessageType::Headline,
BackendWorkspaceMessageType::Announcement => WorkspaceMessageType::Announcement,
BackendWorkspaceMessageType::Unknown => WorkspaceMessageType::Unknown,
}
}
fn workspace_messages_feature_disabled(err: &BackendRequestError) -> bool {
err.status().is_some_and(|status| status.as_u16() == 404)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -1055,4 +1164,55 @@ mod tests {
} }
); );
} }
#[test]
fn workspace_messages_response_maps_backend_messages() {
let response = AccountRequestProcessor::workspace_messages_response(
BackendWorkspaceMessagesResponse {
messages: vec![BackendWorkspaceMessage {
message_id: "headline-id".to_string(),
message_type: BackendWorkspaceMessageType::Headline,
message_body: "Headline body".to_string(),
created_at: Some("2026-06-14T00:00:00Z".to_string()),
archived_at: Some("2026-06-15T00:00:00Z".to_string()),
}],
},
/*feature_enabled*/ true,
)
.expect("workspace message timestamps should parse");
assert_eq!(
response,
GetWorkspaceMessagesResponse {
feature_enabled: true,
messages: vec![WorkspaceMessage {
message_id: "headline-id".to_string(),
message_type: WorkspaceMessageType::Headline,
message_body: "Headline body".to_string(),
created_at: Some(1_781_395_200),
archived_at: Some(1_781_481_600),
}],
}
);
}
#[test]
fn workspace_messages_feature_disabled_only_for_not_found() {
let cases = [
(reqwest::StatusCode::NOT_FOUND, true),
(reqwest::StatusCode::UNAUTHORIZED, false),
(reqwest::StatusCode::FORBIDDEN, false),
];
for (status, expected) in cases {
let err = BackendRequestError::UnexpectedStatus {
method: "GET".to_string(),
url: "https://example.test/api/codex/workspace-messages".to_string(),
status,
content_type: "application/json".to_string(),
body: "{}".to_string(),
};
assert_eq!(workspace_messages_feature_disabled(&err), expected);
}
}
} }
+38
View File
@@ -1,5 +1,6 @@
use crate::types::AccountsCheckResponse; use crate::types::AccountsCheckResponse;
use crate::types::CodeTaskDetailsResponse; use crate::types::CodeTaskDetailsResponse;
use crate::types::CodexWorkspaceMessagesResponse;
use crate::types::ConfigBundleResponse; use crate::types::ConfigBundleResponse;
use crate::types::PaginatedListTaskListItem; use crate::types::PaginatedListTaskListItem;
use crate::types::RateLimitReachedKind as BackendRateLimitReachedKind; use crate::types::RateLimitReachedKind as BackendRateLimitReachedKind;
@@ -19,6 +20,7 @@ use codex_protocol::protocol::RateLimitSnapshot;
use codex_protocol::protocol::RateLimitWindow; use codex_protocol::protocol::RateLimitWindow;
use codex_protocol::protocol::SpendControlLimitSnapshot; use codex_protocol::protocol::SpendControlLimitSnapshot;
use reqwest::StatusCode; use reqwest::StatusCode;
use reqwest::header::CACHE_CONTROL;
use reqwest::header::CONTENT_TYPE; use reqwest::header::CONTENT_TYPE;
use reqwest::header::HeaderMap; use reqwest::header::HeaderMap;
use reqwest::header::HeaderName; use reqwest::header::HeaderName;
@@ -430,6 +432,20 @@ impl Client {
.map_err(RequestError::from) .map_err(RequestError::from)
} }
pub async fn list_workspace_messages(
&self,
) -> std::result::Result<CodexWorkspaceMessagesResponse, RequestError> {
let url = self.workspace_messages_url();
let req = self
.http
.get(&url)
.headers(self.headers())
.header(CACHE_CONTROL, HeaderValue::from_static("no-store"));
let (body, ct) = self.exec_request_detailed(req, "GET", &url).await?;
self.decode_json::<CodexWorkspaceMessagesResponse>(&url, &ct, &body)
.map_err(RequestError::from)
}
/// Create a new task (user turn) by POSTing to the appropriate backend path /// Create a new task (user turn) by POSTing to the appropriate backend path
/// based on `path_style`. Returns the created task id. /// based on `path_style`. Returns the created task id.
pub async fn create_task(&self, request_body: serde_json::Value) -> Result<String> { pub async fn create_task(&self, request_body: serde_json::Value) -> Result<String> {
@@ -570,6 +586,13 @@ impl Client {
} }
} }
fn workspace_messages_url(&self) -> String {
match self.path_style {
PathStyle::CodexApi => format!("{}/api/codex/workspace-messages", self.base_url),
PathStyle::ChatGptApi => format!("{}/wham/workspace-messages", self.base_url),
}
}
fn map_rate_limit_window( fn map_rate_limit_window(
window: Option<Option<Box<crate::types::RateLimitWindowSnapshot>>>, window: Option<Option<Box<crate::types::RateLimitWindowSnapshot>>>,
) -> Option<RateLimitWindow> { ) -> Option<RateLimitWindow> {
@@ -936,6 +959,21 @@ mod tests {
); );
} }
#[test]
fn workspace_messages_uses_expected_paths() {
let codex_client = test_client("https://example.test", PathStyle::CodexApi);
assert_eq!(
codex_client.workspace_messages_url(),
"https://example.test/api/codex/workspace-messages"
);
let chatgpt_client = test_client("https://chatgpt.com/backend-api", PathStyle::ChatGptApi);
assert_eq!(
chatgpt_client.workspace_messages_url(),
"https://chatgpt.com/backend-api/wham/workspace-messages"
);
}
fn test_client(base_url: &str, path_style: PathStyle) -> Client { fn test_client(base_url: &str, path_style: PathStyle) -> Client {
Client { Client {
base_url: base_url.to_string(), base_url: base_url.to_string(),
+3
View File
@@ -8,6 +8,9 @@ pub use types::AccountEntry;
pub use types::AccountsCheckResponse; pub use types::AccountsCheckResponse;
pub use types::CodeTaskDetailsResponse; pub use types::CodeTaskDetailsResponse;
pub use types::CodeTaskDetailsResponseExt; pub use types::CodeTaskDetailsResponseExt;
pub use types::CodexWorkspaceMessage;
pub use types::CodexWorkspaceMessageType;
pub use types::CodexWorkspaceMessagesResponse;
pub use types::ConfigBundleResponse; pub use types::ConfigBundleResponse;
pub use types::ConsumeRateLimitResetCreditCode; pub use types::ConsumeRateLimitResetCreditCode;
pub use types::ConsumeRateLimitResetCreditResponse; pub use types::ConsumeRateLimitResetCreditResponse;
+83
View File
@@ -36,6 +36,23 @@ pub(crate) struct RateLimitStatusWithResetCredits {
pub rate_limit_reset_credits: Option<RateLimitResetCreditsSummary>, pub rate_limit_reset_credits: Option<RateLimitResetCreditsSummary>,
} }
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
pub struct CodexWorkspaceMessagesResponse {
#[serde(default)]
pub messages: Vec<CodexWorkspaceMessage>,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
pub struct CodexWorkspaceMessage {
pub message_id: String,
pub message_type: CodexWorkspaceMessageType,
pub message_body: String,
#[serde(default)]
pub created_at: Option<String>,
#[serde(default)]
pub archived_at: Option<String>,
}
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] #[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum ConsumeRateLimitResetCreditCode { pub enum ConsumeRateLimitResetCreditCode {
@@ -52,6 +69,15 @@ pub struct ConsumeRateLimitResetCreditResponse {
pub windows_reset: i64, pub windows_reset: i64,
} }
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CodexWorkspaceMessageType {
Headline,
Announcement,
#[serde(other)]
Unknown,
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct AccountsCheckResponse { pub struct AccountsCheckResponse {
pub accounts: Vec<AccountEntry>, pub accounts: Vec<AccountEntry>,
@@ -520,4 +546,61 @@ Second line"
.expect("error should be present"); .expect("error should be present");
assert_eq!(msg, "APPLY_FAILED: Patch could not be applied"); assert_eq!(msg, "APPLY_FAILED: Patch could not be applied");
} }
#[test]
fn workspace_messages_response_deserializes_messages() {
let response: CodexWorkspaceMessagesResponse = serde_json::from_value(serde_json::json!({
"messages": [
{
"message_id": "headline-id",
"message_type": "headline",
"message_body": "Headline body",
"created_at": "2026-06-14T00:00:00Z",
"archived_at": null
},
{
"message_id": "announcement-id",
"message_type": "announcement",
"message_body": "Announcement body",
"created_at": "2026-06-14T01:00:00Z",
"archived_at": null
},
{
"message_id": "unknown-id",
"message_type": "unknown",
"message_body": "Unknown body"
}
]
}))
.expect("workspace messages response should deserialize");
assert_eq!(
response,
CodexWorkspaceMessagesResponse {
messages: vec![
CodexWorkspaceMessage {
message_id: "headline-id".to_string(),
message_type: CodexWorkspaceMessageType::Headline,
message_body: "Headline body".to_string(),
created_at: Some("2026-06-14T00:00:00Z".to_string()),
archived_at: None,
},
CodexWorkspaceMessage {
message_id: "announcement-id".to_string(),
message_type: CodexWorkspaceMessageType::Announcement,
message_body: "Announcement body".to_string(),
created_at: Some("2026-06-14T01:00:00Z".to_string()),
archived_at: None,
},
CodexWorkspaceMessage {
message_id: "unknown-id".to_string(),
message_type: CodexWorkspaceMessageType::Unknown,
message_body: "Unknown body".to_string(),
created_at: None,
archived_at: None,
},
],
}
);
}
} }
@@ -31,6 +31,8 @@ const TOKEN_ACTIVITY_FETCH_TIMEOUT: std::time::Duration =
std::time::Duration::from_secs(/*secs*/ 15); std::time::Duration::from_secs(/*secs*/ 15);
const RATE_LIMIT_RESET_REQUEST_TIMEOUT: std::time::Duration = const RATE_LIMIT_RESET_REQUEST_TIMEOUT: std::time::Duration =
std::time::Duration::from_secs(/*secs*/ 15); std::time::Duration::from_secs(/*secs*/ 15);
const WORKSPACE_HEADLINE_FETCH_TIMEOUT: std::time::Duration =
std::time::Duration::from_millis(/*millis*/ 2000);
impl App { impl App {
pub(super) fn fetch_mcp_inventory( pub(super) fn fetch_mcp_inventory(
@@ -158,6 +160,29 @@ impl App {
}); });
} }
pub(super) fn refresh_status_line_workspace_headline(
&mut self,
app_server: &AppServerSession,
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(
WORKSPACE_HEADLINE_FETCH_TIMEOUT,
fetch_workspace_messages(request_handle),
)
.await
.map_err(|_| "account/workspaceMessages/read timed out in TUI".to_string())
.and_then(|result| {
result
.map(crate::workspace_messages::workspace_headline_from_response)
.map_err(|err| err.to_string())
});
app_event_tx.send(AppEvent::StatusLineWorkspaceHeadlineUpdated { request_id, result });
});
}
pub(super) fn send_add_credits_nudge_email( pub(super) fn send_add_credits_nudge_email(
&mut self, &mut self,
app_server: &AppServerSession, app_server: &AppServerSession,
@@ -796,6 +821,19 @@ pub(super) async fn consume_rate_limit_reset_credit_request(
.wrap_err("account/rateLimitResetCredit/consume failed in TUI") .wrap_err("account/rateLimitResetCredit/consume failed in TUI")
} }
pub(super) async fn fetch_workspace_messages(
request_handle: AppServerRequestHandle,
) -> Result<codex_app_server_protocol::GetWorkspaceMessagesResponse> {
let request_id = RequestId::String(format!("workspace-messages-{}", Uuid::new_v4()));
request_handle
.request_typed(ClientRequest::GetWorkspaceMessages {
request_id,
params: None,
})
.await
.wrap_err("account/workspaceMessages/read failed in TUI")
}
pub(super) async fn send_add_credits_nudge_email( pub(super) async fn send_add_credits_nudge_email(
request_handle: AppServerRequestHandle, request_handle: AppServerRequestHandle,
credit_type: AddCreditsNudgeCreditType, credit_type: AddCreditsNudgeCreditType,
+11
View File
@@ -716,6 +716,9 @@ impl App {
AppEvent::RefreshTokenActivity { request_id } => { AppEvent::RefreshTokenActivity { request_id } => {
self.refresh_token_activity(app_server, request_id); self.refresh_token_activity(app_server, request_id);
} }
AppEvent::RefreshStatusLineWorkspaceHeadline { request_id } => {
self.refresh_status_line_workspace_headline(app_server, request_id);
}
AppEvent::OpenThreadGoalMenu { thread_id } => { AppEvent::OpenThreadGoalMenu { thread_id } => {
self.open_thread_goal_menu(app_server, thread_id).await; self.open_thread_goal_menu(app_server, thread_id).await;
} }
@@ -2020,6 +2023,14 @@ impl App {
self.chat_widget.set_status_line_git_summary(cwd, summary); self.chat_widget.set_status_line_git_summary(cwd, summary);
self.refresh_status_line(); self.refresh_status_line();
} }
AppEvent::StatusLineWorkspaceHeadlineUpdated { request_id, result } => {
if self
.chat_widget
.set_status_line_workspace_headline(request_id, result)
{
tui.frame_requester().schedule_frame();
}
}
AppEvent::StatusLineSetupCancelled => { AppEvent::StatusLineSetupCancelled => {
self.chat_widget.cancel_status_line_setup(); self.chat_widget.cancel_status_line_setup();
} }
+10
View File
@@ -341,6 +341,11 @@ pub(crate) enum AppEvent {
result: Result<GetAccountTokenUsageResponse, String>, result: Result<GetAccountTokenUsageResponse, String>,
}, },
/// Fetch workspace messages for the status-line headline item.
RefreshStatusLineWorkspaceHeadline {
request_id: u64,
},
/// Commit settled asynchronous usage output after active-output barriers clear. /// Commit settled asynchronous usage output after active-output barriers clear.
CommitPendingUsageOutput, CommitPendingUsageOutput,
@@ -982,6 +987,11 @@ pub(crate) enum AppEvent {
cwd: PathBuf, cwd: PathBuf,
summary: crate::chatwidget::StatusLineGitSummary, summary: crate::chatwidget::StatusLineGitSummary,
}, },
/// Async update of the workspace notification headline for status line rendering.
StatusLineWorkspaceHeadlineUpdated {
request_id: u64,
result: Result<crate::workspace_messages::WorkspaceHeadlineFetchResult, String>,
},
/// Apply a user-confirmed status-line item ordering/selection. /// Apply a user-confirmed status-line item ordering/selection.
StatusLineSetup { StatusLineSetup {
items: Vec<StatusLineItem>, items: Vec<StatusLineItem>,
@@ -137,6 +137,9 @@ pub(crate) enum StatusLineItem {
/// Current thread title (if set by user). /// Current thread title (if set by user).
ThreadTitle, ThreadTitle,
/// Current workspace notification headline.
WorkspaceHeadline,
/// Latest checklist task progress from `update_plan` (if available). /// Latest checklist task progress from `update_plan` (if available).
TaskProgress, TaskProgress,
} }
@@ -185,6 +188,9 @@ impl StatusLineItem {
StatusLineItem::ThreadTitle => { StatusLineItem::ThreadTitle => {
"Current thread title, or thread identifier when unnamed" "Current thread title, or thread identifier when unnamed"
} }
StatusLineItem::WorkspaceHeadline => {
"Workspace notification headline (Enterprise workspaces only; omitted when unavailable)"
}
StatusLineItem::TaskProgress => { StatusLineItem::TaskProgress => {
"Latest task progress from update_plan (omitted until available)" "Latest task progress from update_plan (omitted until available)"
} }
@@ -217,6 +223,7 @@ impl StatusLineItem {
StatusLineItem::FastMode => StatusSurfacePreviewItem::FastMode, StatusLineItem::FastMode => StatusSurfacePreviewItem::FastMode,
StatusLineItem::RawOutput => StatusSurfacePreviewItem::RawOutput, StatusLineItem::RawOutput => StatusSurfacePreviewItem::RawOutput,
StatusLineItem::ThreadTitle => StatusSurfacePreviewItem::ThreadTitle, StatusLineItem::ThreadTitle => StatusSurfacePreviewItem::ThreadTitle,
StatusLineItem::WorkspaceHeadline => StatusSurfacePreviewItem::WorkspaceHeadline,
StatusLineItem::TaskProgress => StatusSurfacePreviewItem::TaskProgress, StatusLineItem::TaskProgress => StatusSurfacePreviewItem::TaskProgress,
} }
} }
@@ -49,7 +49,7 @@ impl StatusLineAccent {
StatusLineItem::FastMode | StatusLineItem::RawOutput => Self::Mode, StatusLineItem::FastMode | StatusLineItem::RawOutput => Self::Mode,
StatusLineItem::Permissions => Self::Mode, StatusLineItem::Permissions => Self::Mode,
StatusLineItem::ApprovalMode => Self::Mode, StatusLineItem::ApprovalMode => Self::Mode,
StatusLineItem::ThreadTitle => Self::Thread, StatusLineItem::ThreadTitle | StatusLineItem::WorkspaceHeadline => Self::Thread,
StatusLineItem::TaskProgress => Self::Progress, StatusLineItem::TaskProgress => Self::Progress,
} }
} }
@@ -30,6 +30,7 @@ pub(crate) enum StatusSurfacePreviewItem {
SessionId, SessionId,
FastMode, FastMode,
RawOutput, RawOutput,
WorkspaceHeadline,
Model, Model,
ModelWithReasoning, ModelWithReasoning,
Reasoning, Reasoning,
@@ -62,6 +63,7 @@ impl StatusSurfacePreviewItem {
StatusSurfacePreviewItem::SessionId => "550e8400-e29b-41d4", StatusSurfacePreviewItem::SessionId => "550e8400-e29b-41d4",
StatusSurfacePreviewItem::FastMode => "Fast on", StatusSurfacePreviewItem::FastMode => "Fast on",
StatusSurfacePreviewItem::RawOutput => "raw output", StatusSurfacePreviewItem::RawOutput => "raw output",
StatusSurfacePreviewItem::WorkspaceHeadline => "Workspace headline",
StatusSurfacePreviewItem::Model => "gpt-5.2-codex", StatusSurfacePreviewItem::Model => "gpt-5.2-codex",
StatusSurfacePreviewItem::ModelWithReasoning => "gpt-5.2-codex medium", StatusSurfacePreviewItem::ModelWithReasoning => "gpt-5.2-codex medium",
StatusSurfacePreviewItem::Reasoning => "medium", StatusSurfacePreviewItem::Reasoning => "medium",
@@ -94,6 +96,7 @@ impl StatusSurfacePreviewItem {
Self::SessionId, Self::SessionId,
Self::FastMode, Self::FastMode,
Self::RawOutput, Self::RawOutput,
Self::WorkspaceHeadline,
Self::Model, Self::Model,
Self::ModelWithReasoning, Self::ModelWithReasoning,
Self::Reasoning, Self::Reasoning,
+11
View File
@@ -727,6 +727,16 @@ pub(crate) struct ChatWidget {
status_line_git_summary_pending: bool, status_line_git_summary_pending: bool,
// True once we've attempted a Git summary lookup for the current CWD. // True once we've attempted a Git summary lookup for the current CWD.
status_line_git_summary_lookup_complete: bool, status_line_git_summary_lookup_complete: bool,
// Cached workspace notification headline for the status line.
status_line_workspace_headline: Option<String>,
// Request ID for the async workspace headline fetch currently in flight.
status_line_workspace_headline_pending_request_id: Option<u64>,
// Request ID to assign to the next workspace headline fetch.
next_status_line_workspace_headline_request_id: u64,
// Last time a workspace headline fetch was requested.
status_line_workspace_headline_last_requested_at: Option<Instant>,
// Set after the backend reports the workspace-message feature gate is disabled.
status_line_workspace_messages_disabled: bool,
// Current thread-goal status shown in the status line when plan mode is inactive. // Current thread-goal status shown in the status line when plan mode is inactive.
current_goal_status_indicator: Option<GoalStatusIndicator>, current_goal_status_indicator: Option<GoalStatusIndicator>,
current_goal_status: Option<GoalStatusState>, current_goal_status: Option<GoalStatusState>,
@@ -1188,6 +1198,7 @@ impl ChatWidget {
{ {
self.refresh_terminal_title(); self.refresh_terminal_title();
} }
self.refresh_status_line_if_workspace_headline_due();
} }
fn flush_active_cell(&mut self) { fn flush_active_cell(&mut self) {
@@ -229,6 +229,11 @@ impl ChatWidget {
status_line_git_summary_cwd: None, status_line_git_summary_cwd: None,
status_line_git_summary_pending: false, status_line_git_summary_pending: false,
status_line_git_summary_lookup_complete: false, status_line_git_summary_lookup_complete: false,
status_line_workspace_headline: None,
status_line_workspace_headline_pending_request_id: None,
next_status_line_workspace_headline_request_id: 0,
status_line_workspace_headline_last_requested_at: None,
status_line_workspace_messages_disabled: false,
current_goal_status_indicator: None, current_goal_status_indicator: None,
current_goal_status: None, current_goal_status: None,
external_editor_state: ExternalEditorState::Closed, external_editor_state: ExternalEditorState::Closed,
+5
View File
@@ -224,6 +224,10 @@ impl ChatWidget {
self.clear_pending_token_activity_refreshes(); self.clear_pending_token_activity_refreshes();
self.clear_pending_rate_limit_reset_requests(); self.clear_pending_rate_limit_reset_requests();
} }
self.status_line_workspace_headline = None;
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.status_account_display = status_account_display; self.status_account_display = status_account_display;
self.plan_type = plan_type; self.plan_type = plan_type;
self.has_chatgpt_account = has_chatgpt_account; self.has_chatgpt_account = has_chatgpt_account;
@@ -232,6 +236,7 @@ impl ChatWidget {
.set_connectors_enabled(self.connectors_enabled()); .set_connectors_enabled(self.connectors_enabled());
self.bottom_pane self.bottom_pane
.set_token_activity_command_enabled(has_codex_backend_auth); .set_token_activity_command_enabled(has_codex_backend_auth);
self.refresh_status_line();
} }
/// Set the syntax theme override in the widget's config copy. /// Set the syntax theme override in the widget's config copy.
@@ -0,0 +1,20 @@
---
source: tui/src/chatwidget/tests/status_surface_previews.rs
expression: status_line_popup_snapshot(&mut chat)
---
Configure Status Line
Select which items to display in the status line.
Type to search
>
› [x] Use theme colors Apply colors from the active /theme
───────────────────────
[x] workspace-headline Workspace notification headline (Enterprise workspaces only; omitted …
[ ] model Current model name
[ ] model-with-reasoning Current model name with reasoning level
[ ] reasoning Current reasoning level
[ ] current-dir Current working directory
[ ] project-name Project name (omitted when unavailable)
Workspace maintenance starts at 5pm
Press space to toggle; ←/→ to move; enter to confirm and close; esc to close
@@ -65,6 +65,11 @@ impl StatusSurfaceSelections {
.status_line_items .status_line_items
.contains(&StatusLineItem::BranchChanges) .contains(&StatusLineItem::BranchChanges)
} }
fn uses_workspace_headline(&self) -> bool {
self.status_line_items
.contains(&StatusLineItem::WorkspaceHeadline)
}
} }
/// Cached project-root display name keyed by the cwd used for the last lookup. /// Cached project-root display name keyed by the cwd used for the last lookup.
@@ -157,6 +162,15 @@ impl ChatWidget {
self.request_status_line_git_summary(cwd); self.request_status_line_git_summary(cwd);
} }
} }
if !selections.uses_workspace_headline() {
self.status_line_workspace_headline = None;
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;
} else {
self.request_status_line_workspace_headline_if_due(Instant::now());
}
} }
fn refresh_status_line_from_selections(&mut self, selections: &StatusSurfaceSelections) { fn refresh_status_line_from_selections(&mut self, selections: &StatusSurfaceSelections) {
@@ -553,6 +567,85 @@ impl ChatWidget {
}); });
} }
fn request_status_line_workspace_headline_if_due(&mut self, now: Instant) {
if !self.status_line_workspace_headline_should_fetch(now) {
return;
}
let request_id = self.next_status_line_workspace_headline_request_id;
self.next_status_line_workspace_headline_request_id = self
.next_status_line_workspace_headline_request_id
.wrapping_add(/*rhs*/ 1);
self.status_line_workspace_headline_pending_request_id = Some(request_id);
self.status_line_workspace_headline_last_requested_at = Some(now);
self.app_event_tx
.send(AppEvent::RefreshStatusLineWorkspaceHeadline { request_id });
}
fn status_line_workspace_headline_should_fetch(&self, now: Instant) -> bool {
if self
.status_line_workspace_headline_pending_request_id
.is_some()
|| self.status_line_workspace_messages_disabled
|| !self.has_codex_backend_auth
{
return false;
}
self.status_line_workspace_headline_last_requested_at
.is_none_or(|last_requested_at| {
now.saturating_duration_since(last_requested_at)
>= crate::workspace_messages::WORKSPACE_HEADLINE_REFRESH_INTERVAL
})
}
pub(super) fn refresh_status_line_if_workspace_headline_due(&mut self) {
let now = Instant::now();
if self.status_line_workspace_headline_should_fetch(now)
&& self
.status_line_items_with_invalids()
.0
.contains(&StatusLineItem::WorkspaceHeadline)
{
self.refresh_status_line();
}
}
pub(crate) fn set_status_line_workspace_headline(
&mut self,
request_id: u64,
result: Result<crate::workspace_messages::WorkspaceHeadlineFetchResult, String>,
) -> bool {
if self.status_line_workspace_headline_pending_request_id != Some(request_id) {
return false;
}
self.status_line_workspace_headline_pending_request_id = None;
match result {
Ok(crate::workspace_messages::WorkspaceHeadlineFetchResult::Available(headline)) => {
self.status_line_workspace_messages_disabled = false;
self.status_line_workspace_headline = headline;
}
Ok(crate::workspace_messages::WorkspaceHeadlineFetchResult::FeatureDisabled) => {
self.status_line_workspace_messages_disabled = true;
self.status_line_workspace_headline = None;
}
Err(err) => {
tracing::debug!(error = %err, "failed to fetch workspace headline");
}
}
if !self.status_line_workspace_messages_disabled
&& self
.status_line_items_with_invalids()
.0
.contains(&StatusLineItem::WorkspaceHeadline)
{
self.frame_requester
.schedule_frame_in(crate::workspace_messages::WORKSPACE_HEADLINE_REFRESH_INTERVAL);
}
self.refresh_status_line();
true
}
/// Resolves a display string for one configured status-line item. /// Resolves a display string for one configured status-line item.
/// ///
/// Returning `None` means "omit this item for now", not "configuration error". Callers rely on /// Returning `None` means "omit this item for now", not "configuration error". Callers rely on
@@ -653,6 +746,7 @@ impl ChatWidget {
} }
}, },
), ),
StatusLineItem::WorkspaceHeadline => self.status_line_workspace_headline.clone(),
StatusLineItem::TaskProgress => self.terminal_title_task_progress(), StatusLineItem::TaskProgress => self.terminal_title_task_progress(),
} }
} }
@@ -693,6 +787,7 @@ impl ChatWidget {
StatusSurfacePreviewItem::SessionId => StatusLineItem::SessionId, StatusSurfacePreviewItem::SessionId => StatusLineItem::SessionId,
StatusSurfacePreviewItem::FastMode => StatusLineItem::FastMode, StatusSurfacePreviewItem::FastMode => StatusLineItem::FastMode,
StatusSurfacePreviewItem::RawOutput => StatusLineItem::RawOutput, StatusSurfacePreviewItem::RawOutput => StatusLineItem::RawOutput,
StatusSurfacePreviewItem::WorkspaceHeadline => StatusLineItem::WorkspaceHeadline,
StatusSurfacePreviewItem::Model => StatusLineItem::ModelName, StatusSurfacePreviewItem::Model => StatusLineItem::ModelName,
StatusSurfacePreviewItem::ModelWithReasoning => StatusLineItem::ModelWithReasoning, StatusSurfacePreviewItem::ModelWithReasoning => StatusLineItem::ModelWithReasoning,
StatusSurfacePreviewItem::Reasoning => StatusLineItem::Reasoning, StatusSurfacePreviewItem::Reasoning => StatusLineItem::Reasoning,
@@ -14,6 +14,15 @@ fn enable_test_ambient_pet(chat: &mut ChatWidget) {
chat.install_test_ambient_pet_for_tests(/*animations_enabled*/ false); chat.install_test_ambient_pet_for_tests(/*animations_enabled*/ false);
} }
fn take_workspace_headline_request_id(
rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
) -> u64 {
match rx.try_recv() {
Ok(AppEvent::RefreshStatusLineWorkspaceHeadline { request_id }) => request_id,
event => panic!("expected workspace headline refresh, got {event:?}"),
}
}
/// Receiving a token usage update without usage clears the context indicator. /// Receiving a token usage update without usage clears the context indicator.
#[tokio::test] #[tokio::test]
async fn token_count_none_resets_context_indicator() { async fn token_count_none_resets_context_indicator() {
@@ -2141,6 +2150,192 @@ async fn status_line_legacy_context_usage_renders_context_used_percent() {
); );
} }
#[tokio::test]
async fn status_line_workspace_headline_renders_cached_value() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]);
chat.status_line_workspace_headline = Some("Workspace maintenance starts at 5pm".to_string());
chat.refresh_status_line();
assert_eq!(
status_line_text(&chat),
Some("Workspace maintenance starts at 5pm".to_string())
);
assert!(
drain_insert_history(&mut rx).is_empty(),
"workspace-headline should be a valid status line item"
);
}
#[tokio::test]
async fn status_line_workspace_headline_omits_when_unavailable() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
chat.config.tui_status_line = Some(vec![
"workspace-headline".to_string(),
"run-state".to_string(),
]);
chat.refresh_status_line();
assert_eq!(status_line_text(&chat), Some("Ready".to_string()));
assert!(
drain_insert_history(&mut rx).is_empty(),
"workspace-headline should be omitted without warning when no headline is cached"
);
}
#[tokio::test]
async fn workspace_headline_update_applies_feature_disabled_result() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]);
chat.status_line_workspace_headline = Some("Old headline".to_string());
let request_id = 3;
chat.status_line_workspace_headline_pending_request_id = Some(request_id);
assert!(chat.set_status_line_workspace_headline(
request_id,
Ok(crate::workspace_messages::WorkspaceHeadlineFetchResult::FeatureDisabled),
));
assert_eq!(status_line_text(&chat), None);
assert!(chat.status_line_workspace_messages_disabled);
}
#[tokio::test]
async fn workspace_headline_update_applies_available_headline() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]);
let request_id = 4;
chat.status_line_workspace_headline_pending_request_id = Some(request_id);
assert!(chat.set_status_line_workspace_headline(
request_id,
Ok(
crate::workspace_messages::WorkspaceHeadlineFetchResult::Available(Some(
"Fresh workspace headline".to_string(),
))
),
));
assert_eq!(
status_line_text(&chat),
Some("Fresh workspace headline".to_string())
);
assert!(!chat.status_line_workspace_messages_disabled);
}
#[tokio::test]
async fn account_update_clears_workspace_headline_state() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]);
chat.status_line_workspace_headline = Some("Old workspace headline".to_string());
chat.status_line_workspace_headline_pending_request_id = Some(5);
chat.status_line_workspace_headline_last_requested_at = Some(Instant::now());
chat.status_line_workspace_messages_disabled = true;
chat.update_account_state(
/*status_account_display*/ None, /*plan_type*/ None,
/*has_chatgpt_account*/ false, /*has_codex_backend_auth*/ false,
);
assert_eq!(
(
status_line_text(&chat),
chat.status_line_workspace_headline_pending_request_id,
chat.status_line_workspace_headline_last_requested_at,
chat.status_line_workspace_messages_disabled,
),
(None, None, None, false)
);
}
#[tokio::test]
async fn workspace_headline_fetch_allows_backend_auth_without_chatgpt_account() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]);
chat.update_account_state(
/*status_account_display*/ None, /*plan_type*/ None,
/*has_chatgpt_account*/ false, /*has_codex_backend_auth*/ true,
);
let request_id = take_workspace_headline_request_id(&mut rx);
assert_eq!(
chat.status_line_workspace_headline_pending_request_id,
Some(request_id)
);
}
#[tokio::test]
async fn account_update_discards_stale_workspace_headline_results() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]);
chat.update_account_state(
Some(StatusAccountDisplay::ChatGpt {
email: Some("first@example.com".to_string()),
plan: None,
}),
/*plan_type*/ None,
/*has_chatgpt_account*/ true,
/*has_codex_backend_auth*/ true,
);
let stale_request_id = take_workspace_headline_request_id(&mut rx);
chat.update_account_state(
Some(StatusAccountDisplay::ChatGpt {
email: Some("second@example.com".to_string()),
plan: None,
}),
/*plan_type*/ None,
/*has_chatgpt_account*/ true,
/*has_codex_backend_auth*/ true,
);
let current_request_id = take_workspace_headline_request_id(&mut rx);
assert_ne!(stale_request_id, current_request_id);
assert!(!chat.set_status_line_workspace_headline(
stale_request_id,
Ok(
crate::workspace_messages::WorkspaceHeadlineFetchResult::Available(Some(
"First account headline".to_string(),
))
),
));
assert_eq!(
(
chat.status_line_workspace_headline.clone(),
chat.status_line_workspace_headline_pending_request_id,
chat.status_line_workspace_messages_disabled,
),
(None, Some(current_request_id), false)
);
assert!(chat.set_status_line_workspace_headline(
current_request_id,
Ok(
crate::workspace_messages::WorkspaceHeadlineFetchResult::Available(Some(
"Second account headline".to_string(),
))
),
));
assert!(!chat.set_status_line_workspace_headline(
stale_request_id,
Ok(crate::workspace_messages::WorkspaceHeadlineFetchResult::FeatureDisabled),
));
assert_eq!(
(
status_line_text(&chat),
chat.status_line_workspace_headline_pending_request_id,
chat.status_line_workspace_messages_disabled,
),
(Some("Second account headline".to_string()), None, false,)
);
}
#[tokio::test] #[tokio::test]
async fn status_line_branch_state_resets_when_git_branch_disabled() { async fn status_line_branch_state_resets_when_git_branch_disabled() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -180,6 +180,18 @@ async fn status_line_setup_popup_hardcoded_only_snapshot() {
); );
} }
#[tokio::test]
async fn status_line_setup_popup_workspace_headline_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.status_line_workspace_headline = Some("Workspace maintenance starts at 5pm".to_string());
chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]);
assert_chatwidget_snapshot!(
"status_line_setup_popup_workspace_headline",
status_line_popup_snapshot(&mut chat)
);
}
#[tokio::test] #[tokio::test]
async fn status_surface_preview_lines_mixed_snapshot() { async fn status_surface_preview_lines_mixed_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
+1
View File
@@ -200,6 +200,7 @@ mod width;
#[cfg(any(target_os = "windows", test))] #[cfg(any(target_os = "windows", test))]
mod windows_sandbox; mod windows_sandbox;
mod workspace_command; mod workspace_command;
mod workspace_messages;
mod wrapping; mod wrapping;
+29
View File
@@ -0,0 +1,29 @@
use codex_app_server_protocol::GetWorkspaceMessagesResponse;
use codex_app_server_protocol::WorkspaceMessageType;
use std::time::Duration;
pub(crate) const WORKSPACE_HEADLINE_REFRESH_INTERVAL: Duration = Duration::from_secs(5 * 60);
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum WorkspaceHeadlineFetchResult {
Available(Option<String>),
FeatureDisabled,
}
pub(crate) fn workspace_headline_from_response(
response: GetWorkspaceMessagesResponse,
) -> WorkspaceHeadlineFetchResult {
if !response.feature_enabled {
return WorkspaceHeadlineFetchResult::FeatureDisabled;
}
WorkspaceHeadlineFetchResult::Available(response.messages.into_iter().find_map(|message| {
(message.message_type == WorkspaceMessageType::Headline)
.then(|| message.message_body.trim().to_string())
.filter(|headline| !headline.is_empty())
}))
}
#[cfg(test)]
#[path = "workspace_messages_tests.rs"]
mod tests;
@@ -0,0 +1,51 @@
use super::*;
use codex_app_server_protocol::WorkspaceMessage;
use pretty_assertions::assert_eq;
#[test]
fn workspace_headline_from_response_uses_first_non_empty_headline() {
let response = GetWorkspaceMessagesResponse {
feature_enabled: true,
messages: vec![
WorkspaceMessage {
message_id: "announcement-id".to_string(),
message_type: WorkspaceMessageType::Announcement,
message_body: "Announcement body".to_string(),
created_at: None,
archived_at: None,
},
WorkspaceMessage {
message_id: "empty-headline-id".to_string(),
message_type: WorkspaceMessageType::Headline,
message_body: " ".to_string(),
created_at: None,
archived_at: None,
},
WorkspaceMessage {
message_id: "headline-id".to_string(),
message_type: WorkspaceMessageType::Headline,
message_body: " Workspace headline ".to_string(),
created_at: None,
archived_at: None,
},
],
};
assert_eq!(
workspace_headline_from_response(response),
WorkspaceHeadlineFetchResult::Available(Some("Workspace headline".to_string()))
);
}
#[test]
fn workspace_headline_from_response_reports_feature_disabled() {
let response = GetWorkspaceMessagesResponse {
feature_enabled: false,
messages: Vec::new(),
};
assert_eq!(
workspace_headline_from_response(response),
WorkspaceHeadlineFetchResult::FeatureDisabled
);
}