mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
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:
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": {
|
||||||
|
|||||||
+88
@@ -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",
|
||||||
|
|||||||
+88
@@ -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",
|
||||||
|
|||||||
+67
@@ -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
|
||||||
+14
@@ -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/")]
|
||||||
|
|||||||
@@ -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(×tamp)
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
+20
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user