From 6469022c202f5f3e0ba775f42c42985130f7aad2 Mon Sep 17 00:00:00 2001 From: starr-openai Date: Thu, 2 Apr 2026 14:02:03 -0700 Subject: [PATCH] Add /context window breakdown --- .../schema/json/ClientRequest.json | 39 + .../codex_app_server_protocol.schemas.json | 119 +++ .../codex_app_server_protocol.v2.schemas.json | 119 +++ .../json/v2/ThreadContextReadParams.json | 17 + .../json/v2/ThreadContextReadResponse.json | 80 ++ .../schema/typescript/ClientRequest.ts | 3 +- .../typescript/v2/ThreadContextReadParams.ts | 9 + .../v2/ThreadContextReadResponse.ts | 6 + .../v2/ThreadContextWindowBreakdown.ts | 6 + .../v2/ThreadContextWindowDetail.ts | 5 + .../v2/ThreadContextWindowSection.ts | 6 + .../schema/typescript/v2/index.ts | 5 + .../src/protocol/common.rs | 4 + .../app-server-protocol/src/protocol/v2.rs | 43 ++ codex-rs/app-server/README.md | 25 + .../app-server/src/codex_message_processor.rs | 60 ++ .../app-server/tests/common/mcp_process.rs | 10 + codex-rs/app-server/tests/suite/v2/mod.rs | 1 + .../tests/suite/v2/thread_context_read.rs | 141 ++++ codex-rs/core/src/codex.rs | 19 + codex-rs/core/src/codex_thread.rs | 8 + .../src/context_manager/context_breakdown.rs | 731 ++++++++++++++++++ codex-rs/core/src/context_manager/history.rs | 18 +- codex-rs/core/src/context_manager/mod.rs | 4 + codex-rs/core/src/lib.rs | 3 + codex-rs/docs/codex_mcp_interface.md | 4 +- codex-rs/tui/src/app.rs | 75 ++ codex-rs/tui/src/app_event.rs | 11 + codex-rs/tui/src/chatwidget.rs | 46 ++ .../src/chatwidget/tests/slash_commands.rs | 49 ++ codex-rs/tui/src/context_window.rs | 448 +++++++++++ codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/slash_command.rs | 4 + ...ders_default_context_window_breakdown.snap | 21 + codex-rs/tui/tooltips.txt | 1 + 35 files changed, 2138 insertions(+), 3 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/ThreadContextReadParams.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/ThreadContextReadResponse.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ThreadContextReadParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ThreadContextReadResponse.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ThreadContextWindowBreakdown.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ThreadContextWindowDetail.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ThreadContextWindowSection.ts create mode 100644 codex-rs/app-server/tests/suite/v2/thread_context_read.rs create mode 100644 codex-rs/core/src/context_manager/context_breakdown.rs create mode 100644 codex-rs/tui/src/context_window.rs create mode 100644 codex-rs/tui/src/snapshots/codex_tui__context_window__tests__renders_default_context_window_breakdown.snap diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 7c94419844..97f8b1b828 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2518,6 +2518,21 @@ ], "type": "object" }, + "ThreadContextReadParams": { + "properties": { + "threadId": { + "type": "string" + }, + "verbose": { + "description": "When true, include every contributing row instead of merging repeated labels.", + "type": "boolean" + } + }, + "required": [ + "threadId" + ], + "type": "object" + }, "ThreadForkParams": { "description": "There are two ways to fork a thread: 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. 2. By path: load the thread from disk by path and fork it into a new thread.\n\nIf using path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.", "properties": { @@ -3797,6 +3812,30 @@ "title": "Thread/readRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/context/read" + ], + "title": "Thread/context/readRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadContextReadParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/context/readRequest", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index a589903032..25f696004d 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -578,6 +578,30 @@ "title": "Thread/readRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "thread/context/read" + ], + "title": "Thread/context/readRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ThreadContextReadParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/context/readRequest", + "type": "object" + }, { "properties": { "id": { @@ -12342,6 +12366,101 @@ "title": "ThreadCompactStartResponse", "type": "object" }, + "ThreadContextReadParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + }, + "verbose": { + "description": "When true, include every contributing row instead of merging repeated labels.", + "type": "boolean" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadContextReadParams", + "type": "object" + }, + "ThreadContextReadResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "context": { + "$ref": "#/definitions/v2/ThreadContextWindowBreakdown" + } + }, + "required": [ + "context" + ], + "title": "ThreadContextReadResponse", + "type": "object" + }, + "ThreadContextWindowBreakdown": { + "properties": { + "modelContextWindow": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "sections": { + "items": { + "$ref": "#/definitions/v2/ThreadContextWindowSection" + }, + "type": "array" + }, + "totalTokens": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "sections", + "totalTokens" + ], + "type": "object" + }, + "ThreadContextWindowDetail": { + "properties": { + "label": { + "type": "string" + }, + "tokens": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "label", + "tokens" + ], + "type": "object" + }, + "ThreadContextWindowSection": { + "properties": { + "details": { + "items": { + "$ref": "#/definitions/v2/ThreadContextWindowDetail" + }, + "type": "array" + }, + "label": { + "type": "string" + }, + "tokens": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "details", + "label", + "tokens" + ], + "type": "object" + }, "ThreadForkParams": { "$schema": "http://json-schema.org/draft-07/schema#", "description": "There are two ways to fork a thread: 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. 2. By path: load the thread from disk by path and fork it into a new thread.\n\nIf using path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index f041f8aae8..311c4e9795 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -1153,6 +1153,30 @@ "title": "Thread/readRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/context/read" + ], + "title": "Thread/context/readRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadContextReadParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/context/readRequest", + "type": "object" + }, { "properties": { "id": { @@ -10197,6 +10221,101 @@ "title": "ThreadCompactStartResponse", "type": "object" }, + "ThreadContextReadParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + }, + "verbose": { + "description": "When true, include every contributing row instead of merging repeated labels.", + "type": "boolean" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadContextReadParams", + "type": "object" + }, + "ThreadContextReadResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "context": { + "$ref": "#/definitions/ThreadContextWindowBreakdown" + } + }, + "required": [ + "context" + ], + "title": "ThreadContextReadResponse", + "type": "object" + }, + "ThreadContextWindowBreakdown": { + "properties": { + "modelContextWindow": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "sections": { + "items": { + "$ref": "#/definitions/ThreadContextWindowSection" + }, + "type": "array" + }, + "totalTokens": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "sections", + "totalTokens" + ], + "type": "object" + }, + "ThreadContextWindowDetail": { + "properties": { + "label": { + "type": "string" + }, + "tokens": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "label", + "tokens" + ], + "type": "object" + }, + "ThreadContextWindowSection": { + "properties": { + "details": { + "items": { + "$ref": "#/definitions/ThreadContextWindowDetail" + }, + "type": "array" + }, + "label": { + "type": "string" + }, + "tokens": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "details", + "label", + "tokens" + ], + "type": "object" + }, "ThreadForkParams": { "$schema": "http://json-schema.org/draft-07/schema#", "description": "There are two ways to fork a thread: 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. 2. By path: load the thread from disk by path and fork it into a new thread.\n\nIf using path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadContextReadParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadContextReadParams.json new file mode 100644 index 0000000000..9ee838af04 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadContextReadParams.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + }, + "verbose": { + "description": "When true, include every contributing row instead of merging repeated labels.", + "type": "boolean" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadContextReadParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadContextReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadContextReadResponse.json new file mode 100644 index 0000000000..cc348af16e --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadContextReadResponse.json @@ -0,0 +1,80 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "ThreadContextWindowBreakdown": { + "properties": { + "modelContextWindow": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "sections": { + "items": { + "$ref": "#/definitions/ThreadContextWindowSection" + }, + "type": "array" + }, + "totalTokens": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "sections", + "totalTokens" + ], + "type": "object" + }, + "ThreadContextWindowDetail": { + "properties": { + "label": { + "type": "string" + }, + "tokens": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "label", + "tokens" + ], + "type": "object" + }, + "ThreadContextWindowSection": { + "properties": { + "details": { + "items": { + "$ref": "#/definitions/ThreadContextWindowDetail" + }, + "type": "array" + }, + "label": { + "type": "string" + }, + "tokens": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "details", + "label", + "tokens" + ], + "type": "object" + } + }, + "properties": { + "context": { + "$ref": "#/definitions/ThreadContextWindowBreakdown" + } + }, + "required": [ + "context" + ], + "title": "ThreadContextReadResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index e33a986359..41e9ebc81e 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -44,6 +44,7 @@ import type { SkillsConfigWriteParams } from "./v2/SkillsConfigWriteParams"; import type { SkillsListParams } from "./v2/SkillsListParams"; import type { ThreadArchiveParams } from "./v2/ThreadArchiveParams"; import type { ThreadCompactStartParams } from "./v2/ThreadCompactStartParams"; +import type { ThreadContextReadParams } from "./v2/ThreadContextReadParams"; import type { ThreadForkParams } from "./v2/ThreadForkParams"; import type { ThreadListParams } from "./v2/ThreadListParams"; import type { ThreadLoadedListParams } from "./v2/ThreadLoadedListParams"; @@ -64,4 +65,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "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/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": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "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": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "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/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/context/read", id: RequestId, params: ThreadContextReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "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": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadContextReadParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadContextReadParams.ts new file mode 100644 index 0000000000..557f30d7c6 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadContextReadParams.ts @@ -0,0 +1,9 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ThreadContextReadParams = { threadId: string, +/** + * When true, include every contributing row instead of merging repeated labels. + */ +verbose?: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadContextReadResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadContextReadResponse.ts new file mode 100644 index 0000000000..822e2eed2c --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadContextReadResponse.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ThreadContextWindowBreakdown } from "./ThreadContextWindowBreakdown"; + +export type ThreadContextReadResponse = { context: ThreadContextWindowBreakdown, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadContextWindowBreakdown.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadContextWindowBreakdown.ts new file mode 100644 index 0000000000..751a62a22f --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadContextWindowBreakdown.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ThreadContextWindowSection } from "./ThreadContextWindowSection"; + +export type ThreadContextWindowBreakdown = { modelContextWindow: bigint | null, totalTokens: bigint, sections: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadContextWindowDetail.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadContextWindowDetail.ts new file mode 100644 index 0000000000..1b23cf1017 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadContextWindowDetail.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ThreadContextWindowDetail = { label: string, tokens: bigint, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadContextWindowSection.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadContextWindowSection.ts new file mode 100644 index 0000000000..4cbe75de55 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadContextWindowSection.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ThreadContextWindowDetail } from "./ThreadContextWindowDetail"; + +export type ThreadContextWindowSection = { label: string, tokens: bigint, details: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 495572fd78..4b5e53764f 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -273,6 +273,11 @@ export type { ThreadArchivedNotification } from "./ThreadArchivedNotification"; export type { ThreadClosedNotification } from "./ThreadClosedNotification"; export type { ThreadCompactStartParams } from "./ThreadCompactStartParams"; export type { ThreadCompactStartResponse } from "./ThreadCompactStartResponse"; +export type { ThreadContextReadParams } from "./ThreadContextReadParams"; +export type { ThreadContextReadResponse } from "./ThreadContextReadResponse"; +export type { ThreadContextWindowBreakdown } from "./ThreadContextWindowBreakdown"; +export type { ThreadContextWindowDetail } from "./ThreadContextWindowDetail"; +export type { ThreadContextWindowSection } from "./ThreadContextWindowSection"; export type { ThreadForkParams } from "./ThreadForkParams"; export type { ThreadForkResponse } from "./ThreadForkResponse"; export type { ThreadItem } from "./ThreadItem"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index ebd1ba11c5..052baf18d2 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -317,6 +317,10 @@ client_request_definitions! { params: v2::ThreadReadParams, response: v2::ThreadReadResponse, }, + ThreadContextRead => "thread/context/read" { + params: v2::ThreadContextReadParams, + response: v2::ThreadContextReadResponse, + }, SkillsList => "skills/list" { params: v2::SkillsListParams, response: v2::SkillsListResponse, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index cdc78647a1..572650e358 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3153,6 +3153,49 @@ pub struct ThreadReadResponse { pub thread: Thread, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadContextReadParams { + pub thread_id: String, + /// When true, include every contributing row instead of merging repeated labels. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub verbose: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadContextReadResponse { + pub context: ThreadContextWindowBreakdown, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadContextWindowBreakdown { + pub model_context_window: Option, + pub total_tokens: i64, + pub sections: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadContextWindowSection { + pub label: String, + pub tokens: i64, + pub details: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadContextWindowDetail { + pub label: String, + pub tokens: i64, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index c24c3fd7e8..4b6958d275 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -138,6 +138,7 @@ Example with notification opt-out: - `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. - `thread/loaded/list` — list the thread ids currently loaded in memory. - `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. +- `thread/context/read` — read an approximate semantic breakdown of the current model-visible context for a loaded thread. Pass `verbose: true` to return one row per contributing fragment/item instead of merged summary rows. Unloaded threads are rejected because this endpoint describes live in-memory context, not rollout history. - `thread/metadata/update` — patch stored thread metadata in sqlite; currently supports updating persisted `gitInfo` fields and returns the refreshed `thread`. - `thread/status/changed` — notification emitted when a loaded thread’s status changes (`threadId` + new `status`). - `thread/archive` — move a thread’s rollout file into the archived directory; returns `{}` on success and emits `thread/archived`. @@ -363,6 +364,30 @@ Use `thread/read` to fetch a stored thread by id without resuming it. Pass `incl } } ``` +### Example: Read live context-window usage + +Use `thread/context/read` to fetch an approximate, sectioned breakdown of the current model-visible context for a loaded thread. The response groups usage into `Built-in`, `AGENTS.md`, `Skills`, `Runtime context`, and `Conversation`, with token counts derived from Codex's current byte-based estimator. + +```json +{ "method": "thread/context/read", "id": 24, "params": { "threadId": "thr_123" } } +{ "id": 24, "result": { + "context": { + "modelContextWindow": 272000, + "totalTokens": 18420, + "sections": [ + { + "label": "Conversation", + "tokens": 12300, + "details": [ + { "label": "Tool output", "tokens": 6200 }, + { "label": "User message", "tokens": 2400 } + ] + } + ] + } +} } +``` + ### Example: Update stored thread metadata Use `thread/metadata/update` to patch sqlite-backed metadata for a thread without resuming it. Today this supports persisted `gitInfo`; omitted fields are left unchanged, while explicit `null` clears a stored value. diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 6475531c07..cf5aa3b7f3 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -120,6 +120,11 @@ use codex_app_server_protocol::ThreadBackgroundTerminalsCleanResponse; use codex_app_server_protocol::ThreadClosedNotification; use codex_app_server_protocol::ThreadCompactStartParams; use codex_app_server_protocol::ThreadCompactStartResponse; +use codex_app_server_protocol::ThreadContextReadParams; +use codex_app_server_protocol::ThreadContextReadResponse; +use codex_app_server_protocol::ThreadContextWindowBreakdown; +use codex_app_server_protocol::ThreadContextWindowDetail; +use codex_app_server_protocol::ThreadContextWindowSection; use codex_app_server_protocol::ThreadDecrementElicitationParams; use codex_app_server_protocol::ThreadDecrementElicitationResponse; use codex_app_server_protocol::ThreadForkParams; @@ -777,6 +782,10 @@ impl CodexMessageProcessor { self.thread_read(to_connection_request_id(request_id), params) .await; } + ClientRequest::ThreadContextRead { request_id, params } => { + self.thread_context_read(to_connection_request_id(request_id), params) + .await; + } ClientRequest::ThreadShellCommand { request_id, params } => { self.thread_shell_command(to_connection_request_id(request_id), params) .await; @@ -3677,6 +3686,57 @@ impl CodexMessageProcessor { self.outgoing.send_response(request_id, response).await; } + async fn thread_context_read( + &mut self, + request_id: ConnectionRequestId, + params: ThreadContextReadParams, + ) { + let ThreadContextReadParams { thread_id, verbose } = params; + + let thread_uuid = match ThreadId::from_string(&thread_id) { + Ok(id) => id, + Err(err) => { + self.send_invalid_request_error(request_id, format!("invalid thread id: {err}")) + .await; + return; + } + }; + + let Ok(loaded_thread) = self.thread_manager.get_thread(thread_uuid).await else { + self.send_invalid_request_error( + request_id, + format!("thread not loaded: {thread_uuid}"), + ) + .await; + return; + }; + + let context = loaded_thread.context_window_breakdown(verbose).await; + let response = ThreadContextReadResponse { + context: ThreadContextWindowBreakdown { + model_context_window: context.model_context_window, + total_tokens: context.total_tokens, + sections: context + .sections + .into_iter() + .map(|section| ThreadContextWindowSection { + label: section.label, + tokens: section.tokens, + details: section + .details + .into_iter() + .map(|detail| ThreadContextWindowDetail { + label: detail.label, + tokens: detail.tokens, + }) + .collect(), + }) + .collect(), + }, + }; + self.outgoing.send_response(request_id, response).await; + } + pub(crate) fn thread_created_receiver(&self) -> broadcast::Receiver { self.thread_manager.subscribe_thread_created() } diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 28e48dfdaf..23926c5200 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -59,6 +59,7 @@ use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::SkillsListParams; use codex_app_server_protocol::ThreadArchiveParams; use codex_app_server_protocol::ThreadCompactStartParams; +use codex_app_server_protocol::ThreadContextReadParams; use codex_app_server_protocol::ThreadForkParams; use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadLoadedListParams; @@ -448,6 +449,15 @@ impl McpProcess { self.send_request("thread/read", params).await } + /// Send a `thread/context/read` JSON-RPC request. + pub async fn send_thread_context_read_request( + &mut self, + params: ThreadContextReadParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("thread/context/read", params).await + } + /// Send a `model/list` JSON-RPC request. pub async fn send_list_models_request( &mut self, diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index b8d5546580..ecb16c7185 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -31,6 +31,7 @@ mod review; mod safety_check_downgrade; mod skills_list; mod thread_archive; +mod thread_context_read; mod thread_fork; mod thread_list; mod thread_loaded_list; diff --git a/codex-rs/app-server/tests/suite/v2/thread_context_read.rs b/codex-rs/app-server/tests/suite/v2/thread_context_read.rs new file mode 100644 index 0000000000..080c1e8433 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/thread_context_read.rs @@ -0,0 +1,141 @@ +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_mock_responses_server_repeating_assistant; +use app_test_support::to_response; +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadContextReadParams; +use codex_app_server_protocol::ThreadContextReadResponse; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use pretty_assertions::assert_eq; +use std::path::Path; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +#[tokio::test] +async fn thread_context_read_returns_live_breakdown_for_loaded_thread() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let context_id = mcp + .send_thread_context_read_request(ThreadContextReadParams { + thread_id: thread.id, + verbose: true, + }) + .await?; + let context_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(context_id)), + ) + .await??; + let ThreadContextReadResponse { context } = + to_response::(context_resp)?; + + assert!( + context.total_tokens > 0, + "expected non-zero base instruction usage" + ); + assert_eq!( + context + .sections + .iter() + .map(|section| section.label.as_str()) + .collect::>(), + vec!["Built-in"] + ); + assert_eq!( + context.sections[0] + .details + .iter() + .map(|detail| detail.label.as_str()) + .collect::>(), + vec!["Base instructions"] + ); + assert_eq!( + context.sections[0].tokens, + context.sections[0] + .details + .iter() + .map(|detail| detail.tokens) + .sum::() + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_context_read_rejects_unloaded_thread() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let context_id = mcp + .send_thread_context_read_request(ThreadContextReadParams { + thread_id: "12345678-1234-1234-1234-123456789012".to_string(), + verbose: false, + }) + .await?; + let context_err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(context_id)), + ) + .await??; + + assert!( + context_err + .error + .message + .contains("thread not loaded: 12345678-1234-1234-1234-123456789012"), + "unexpected error: {}", + context_err.error.message + ); + + Ok(()) +} + +fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 758b592047..59d6c3ac2a 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -184,6 +184,7 @@ use crate::config::GhostSnapshotConfig; use crate::config::StartedNetworkProxy; use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; +use crate::context_manager::ContextWindowBreakdown; use crate::context_manager::TotalTokenUsageBreakdown; use crate::environment_context::EnvironmentContext; use codex_config::CONFIG_TOML_FILE; @@ -2188,6 +2189,24 @@ impl Session { state.history.get_total_token_usage_breakdown() } + pub(crate) async fn get_context_window_breakdown( + &self, + verbose: bool, + ) -> ContextWindowBreakdown { + let state = self.state.lock().await; + let base_instructions = BaseInstructions { + text: state.session_configuration.base_instructions.clone(), + }; + let model_context_window = state + .token_info() + .and_then(|info| info.model_context_window); + state.history.get_context_window_breakdown( + &base_instructions, + model_context_window, + verbose, + ) + } + pub(crate) async fn total_token_usage(&self) -> Option { let state = self.state.lock().await; state.token_info().map(|info| info.total_token_usage) diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 252b1b6ae3..a46da21400 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -2,6 +2,7 @@ use crate::agent::AgentStatus; use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; +use crate::context_manager::ContextWindowBreakdown; use crate::file_watcher::WatchRegistration; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; @@ -131,6 +132,13 @@ impl CodexThread { self.codex.session.total_token_usage().await } + pub async fn context_window_breakdown(&self, verbose: bool) -> ContextWindowBreakdown { + self.codex + .session + .get_context_window_breakdown(verbose) + .await + } + /// Records a user-role session-prefix message without creating a new user turn boundary. pub(crate) async fn inject_user_message_without_turn(&self, message: String) { let message = ResponseItem::Message { diff --git a/codex-rs/core/src/context_manager/context_breakdown.rs b/codex-rs/core/src/context_manager/context_breakdown.rs new file mode 100644 index 0000000000..1f94df3fff --- /dev/null +++ b/codex-rs/core/src/context_manager/context_breakdown.rs @@ -0,0 +1,731 @@ +use codex_instructions::AGENTS_MD_FRAGMENT; +use codex_instructions::SKILL_FRAGMENT; +use codex_protocol::items::parse_hook_prompt_fragment; +use codex_protocol::models::BaseInstructions; +use codex_protocol::models::ContentItem; +use codex_protocol::models::LocalShellAction; +use codex_protocol::models::MessagePhase; +use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::APPS_INSTRUCTIONS_OPEN_TAG; +use codex_protocol::protocol::COLLABORATION_MODE_OPEN_TAG; +use codex_protocol::protocol::PLUGINS_INSTRUCTIONS_OPEN_TAG; +use codex_protocol::protocol::REALTIME_CONVERSATION_OPEN_TAG; +use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG; + +use crate::context_manager::history::estimate_item_token_count; +use crate::contextual_user_message::ENVIRONMENT_CONTEXT_FRAGMENT; +use crate::contextual_user_message::SUBAGENT_NOTIFICATION_FRAGMENT; +use crate::contextual_user_message::TURN_ABORTED_FRAGMENT; +use crate::contextual_user_message::USER_SHELL_COMMAND_FRAGMENT; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContextWindowBreakdown { + pub model_context_window: Option, + pub total_tokens: i64, + pub sections: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContextWindowSection { + pub label: String, + pub tokens: i64, + pub details: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContextWindowDetail { + pub label: String, + pub tokens: i64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ContextSectionKind { + BuiltIn, + Agents, + Skills, + Runtime, + Conversation, +} + +#[derive(Debug, Clone)] +struct DetailAllocation { + section: ContextSectionKind, + label: String, + estimated_tokens: i64, +} + +#[derive(Debug, Default)] +struct SectionAccumulator { + section: Option, +} + +#[derive(Debug, Default)] +struct BreakdownAccumulator { + built_in: SectionAccumulator, + agents: SectionAccumulator, + skills: SectionAccumulator, + runtime: SectionAccumulator, + conversation: SectionAccumulator, + verbose: bool, +} + +pub(super) fn build_context_window_breakdown( + items: &[ResponseItem], + base_instructions: &BaseInstructions, + model_context_window: Option, + verbose: bool, +) -> ContextWindowBreakdown { + let mut accumulator = BreakdownAccumulator { + verbose, + ..Default::default() + }; + let base_instruction_tokens = estimate_text_tokens(&base_instructions.text); + accumulator.add_detail( + ContextSectionKind::BuiltIn, + "Base instructions".to_string(), + base_instruction_tokens, + ); + + for item in items { + accumulator.add_item(item); + } + + ContextWindowBreakdown { + model_context_window, + total_tokens: base_instruction_tokens.saturating_add( + items + .iter() + .map(estimate_item_token_count) + .fold(0i64, i64::saturating_add), + ), + sections: accumulator.into_sections(), + } +} + +impl BreakdownAccumulator { + fn add_item(&mut self, item: &ResponseItem) { + let item_tokens = estimate_item_token_count(item); + match item { + ResponseItem::Message { + id, + role, + content, + end_turn, + phase, + } => { + let mut details = + classify_message_content(id.as_ref(), role, content, *end_turn, phase.as_ref()); + scale_detail_tokens(&mut details, item_tokens); + for detail in details { + self.add_detail(detail.section, detail.label, detail.estimated_tokens); + } + } + ResponseItem::Reasoning { .. } => { + self.add_detail( + ContextSectionKind::Conversation, + "Reasoning".to_string(), + item_tokens, + ); + } + ResponseItem::LocalShellCall { action, .. } => { + let command = match action { + LocalShellAction::Exec(exec) => exec.command.join(" "), + }; + self.add_detail( + ContextSectionKind::Conversation, + format!("Shell call: {command}"), + item_tokens, + ); + } + ResponseItem::FunctionCall { name, .. } => { + self.add_detail( + ContextSectionKind::Conversation, + format!("Tool call: {name}"), + item_tokens, + ); + } + ResponseItem::FunctionCallOutput { .. } => { + self.add_detail( + ContextSectionKind::Conversation, + "Tool output".to_string(), + item_tokens, + ); + } + ResponseItem::ToolSearchCall { execution, .. } => { + self.add_detail( + ContextSectionKind::Conversation, + format!("Tool search: {execution}"), + item_tokens, + ); + } + ResponseItem::CustomToolCall { name, .. } => { + self.add_detail( + ContextSectionKind::Conversation, + format!("Custom tool call: {name}"), + item_tokens, + ); + } + ResponseItem::CustomToolCallOutput { name, .. } => { + self.add_detail( + ContextSectionKind::Conversation, + format!( + "Custom tool output{}", + name.as_ref() + .map(|value| format!(": {value}")) + .unwrap_or_default() + ), + item_tokens, + ); + } + ResponseItem::ToolSearchOutput { execution, .. } => { + self.add_detail( + ContextSectionKind::Conversation, + format!("Tool search output: {execution}"), + item_tokens, + ); + } + ResponseItem::WebSearchCall { .. } => { + self.add_detail( + ContextSectionKind::Conversation, + "Web search call".to_string(), + item_tokens, + ); + } + ResponseItem::ImageGenerationCall { .. } => { + self.add_detail( + ContextSectionKind::Conversation, + "Image generation call".to_string(), + item_tokens, + ); + } + ResponseItem::Compaction { .. } => { + self.add_detail( + ContextSectionKind::Conversation, + "Compaction summary".to_string(), + item_tokens, + ); + } + ResponseItem::GhostSnapshot { .. } | ResponseItem::Other => {} + } + } + + fn add_detail(&mut self, section: ContextSectionKind, label: String, tokens: i64) { + if tokens <= 0 { + return; + } + let verbose = self.verbose; + let section = self + .section_accumulator(section) + .section + .get_or_insert_with(|| ContextWindowSection { + label: section.label().to_string(), + tokens: 0, + details: Vec::new(), + }); + section.tokens = section.tokens.saturating_add(tokens); + if verbose { + section.details.push(ContextWindowDetail { label, tokens }); + return; + } + if let Some(existing) = section + .details + .iter_mut() + .find(|detail| detail.label == label) + { + existing.tokens = existing.tokens.saturating_add(tokens); + } else { + section.details.push(ContextWindowDetail { label, tokens }); + } + } + + fn section_accumulator(&mut self, section: ContextSectionKind) -> &mut SectionAccumulator { + match section { + ContextSectionKind::BuiltIn => &mut self.built_in, + ContextSectionKind::Agents => &mut self.agents, + ContextSectionKind::Skills => &mut self.skills, + ContextSectionKind::Runtime => &mut self.runtime, + ContextSectionKind::Conversation => &mut self.conversation, + } + } + + fn into_sections(self) -> Vec { + let mut sections: Vec = [ + self.built_in.section, + self.agents.section, + self.skills.section, + self.runtime.section, + self.conversation.section, + ] + .into_iter() + .flatten() + .collect(); + for section in &mut sections { + section.details.sort_by(|left, right| { + right + .tokens + .cmp(&left.tokens) + .then(left.label.cmp(&right.label)) + }); + } + sections.sort_by(|left, right| { + right + .tokens + .cmp(&left.tokens) + .then(section_order(&left.label).cmp(§ion_order(&right.label))) + .then(left.label.cmp(&right.label)) + }); + sections + } +} + +fn classify_message_content( + id: Option<&String>, + role: &str, + content: &[ContentItem], + end_turn: Option, + phase: Option<&MessagePhase>, +) -> Vec { + if content.is_empty() { + return vec![DetailAllocation { + section: section_for_message_role(role), + label: format_message_label(role, phase), + estimated_tokens: estimate_message_tokens(id, role, content, end_turn, phase), + }]; + } + + content + .iter() + .map(|content_item| { + let (section, label) = classify_content_item(role, content_item, phase); + DetailAllocation { + section, + label, + estimated_tokens: estimate_message_tokens( + id, + role, + std::slice::from_ref(content_item), + end_turn, + phase, + ), + } + }) + .collect() +} + +fn classify_content_item( + role: &str, + content_item: &ContentItem, + phase: Option<&MessagePhase>, +) -> (ContextSectionKind, String) { + let (ContentItem::InputText { text } | ContentItem::OutputText { text }) = content_item else { + return ( + ContextSectionKind::Conversation, + format_message_label(role, phase), + ); + }; + + if role == "developer" { + return classify_developer_text(text); + } + if role == "user" { + return classify_user_text(text, phase); + } + ( + section_for_message_role(role), + format_message_label(role, phase), + ) +} + +fn classify_developer_text(text: &str) -> (ContextSectionKind, String) { + let trimmed = text.trim_start(); + if starts_with_tag(trimmed, SKILLS_INSTRUCTIONS_OPEN_TAG) { + return ( + ContextSectionKind::Skills, + format!( + "Implicit skills catalog ({} skills)", + count_catalog_entries(trimmed) + ), + ); + } + if starts_with_tag(trimmed, APPS_INSTRUCTIONS_OPEN_TAG) { + return ( + ContextSectionKind::BuiltIn, + "Apps connector instructions".to_string(), + ); + } + if starts_with_tag(trimmed, PLUGINS_INSTRUCTIONS_OPEN_TAG) { + return ( + ContextSectionKind::BuiltIn, + format!( + "Plugin instructions ({} plugins)", + count_catalog_entries(trimmed) + ), + ); + } + if starts_with_tag(trimmed, "") { + return ( + ContextSectionKind::BuiltIn, + "Permission instructions".to_string(), + ); + } + if starts_with_tag(trimmed, "") { + return ( + ContextSectionKind::BuiltIn, + "Model switch instructions".to_string(), + ); + } + if starts_with_tag(trimmed, COLLABORATION_MODE_OPEN_TAG) { + return ( + ContextSectionKind::BuiltIn, + "Collaboration mode instructions".to_string(), + ); + } + if starts_with_tag(trimmed, "") { + return ( + ContextSectionKind::BuiltIn, + "Personality instructions".to_string(), + ); + } + if starts_with_tag(trimmed, REALTIME_CONVERSATION_OPEN_TAG) { + return (ContextSectionKind::Runtime, "Realtime context".to_string()); + } + ( + ContextSectionKind::BuiltIn, + "Developer instructions".to_string(), + ) +} + +fn classify_user_text(text: &str, phase: Option<&MessagePhase>) -> (ContextSectionKind, String) { + if AGENTS_MD_FRAGMENT.matches_text(text) { + return (ContextSectionKind::Agents, format_agents_label(text)); + } + if SKILL_FRAGMENT.matches_text(text) { + return (ContextSectionKind::Skills, format_skill_label(text)); + } + if ENVIRONMENT_CONTEXT_FRAGMENT.matches_text(text) { + return ( + ContextSectionKind::Runtime, + "Environment context".to_string(), + ); + } + if USER_SHELL_COMMAND_FRAGMENT.matches_text(text) { + return ( + ContextSectionKind::Runtime, + "User shell command".to_string(), + ); + } + if TURN_ABORTED_FRAGMENT.matches_text(text) { + return ( + ContextSectionKind::Runtime, + "Turn aborted marker".to_string(), + ); + } + if SUBAGENT_NOTIFICATION_FRAGMENT.matches_text(text) { + return ( + ContextSectionKind::Runtime, + "Subagent notification".to_string(), + ); + } + if parse_hook_prompt_fragment(text).is_some() { + return ( + ContextSectionKind::Runtime, + "Hook prompt context".to_string(), + ); + } + ( + ContextSectionKind::Conversation, + format_message_label("user", phase), + ) +} + +fn estimate_message_tokens( + id: Option<&String>, + role: &str, + content: &[ContentItem], + end_turn: Option, + phase: Option<&MessagePhase>, +) -> i64 { + estimate_item_token_count(&ResponseItem::Message { + id: id.cloned(), + role: role.to_string(), + content: content.to_vec(), + end_turn, + phase: phase.cloned(), + }) +} + +fn estimate_text_tokens(text: &str) -> i64 { + codex_utils_output_truncation::approx_token_count(text) + .try_into() + .unwrap_or(i64::MAX) +} + +fn format_agents_label(text: &str) -> String { + let directory = text + .trim_start() + .strip_prefix(AGENTS_MD_FRAGMENT.start_marker()) + .and_then(|rest| rest.lines().next()) + .map(str::trim) + .filter(|line| !line.is_empty()) + .unwrap_or("current workspace"); + format!("AGENTS.md instructions for {directory}") +} + +fn format_skill_label(text: &str) -> String { + let name = extract_tag_text(text, "name").unwrap_or("unknown skill"); + let path = extract_tag_text(text, "path"); + match path { + Some(path) => format!("Skill: {name} ({path})"), + None => format!("Skill: {name}"), + } +} + +fn extract_tag_text<'a>(text: &'a str, tag_name: &str) -> Option<&'a str> { + let open_tag = format!("<{tag_name}>"); + let close_tag = format!(""); + let start = text.find(&open_tag)?.saturating_add(open_tag.len()); + let value = text.get(start..)?; + let end = value.find(&close_tag)?; + Some(value[..end].trim()) +} + +fn count_catalog_entries(text: &str) -> usize { + let mut in_available_section = false; + let mut count = 0; + for line in text.lines() { + match line.trim() { + "### Available skills" | "### Available plugins" => { + in_available_section = true; + } + "### How to use skills" | "### How to use plugins" => { + in_available_section = false; + } + line if in_available_section && line.starts_with("- ") => { + count += 1; + } + _ => {} + } + } + count +} + +fn starts_with_tag(text: &str, tag: &str) -> bool { + text.get(..tag.len()) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case(tag)) +} + +fn scale_detail_tokens(details: &mut [DetailAllocation], target_tokens: i64) { + if details.is_empty() { + return; + } + let total_estimated = details + .iter() + .map(|detail| detail.estimated_tokens.max(0)) + .fold(0i64, i64::saturating_add); + if total_estimated == 0 { + let last = details.len() - 1; + for detail in &mut details[..last] { + detail.estimated_tokens = 0; + } + details[last].estimated_tokens = target_tokens.max(0); + return; + } + + let mut assigned = 0i64; + let last = details.len() - 1; + for detail in &mut details[..last] { + detail.estimated_tokens = detail + .estimated_tokens + .max(0) + .saturating_mul(target_tokens.max(0)) + / total_estimated; + assigned = assigned.saturating_add(detail.estimated_tokens); + } + details[last].estimated_tokens = target_tokens.max(0).saturating_sub(assigned); +} + +fn format_message_label(role: &str, phase: Option<&MessagePhase>) -> String { + match (role, phase) { + ("assistant", Some(MessagePhase::Commentary)) => { + "Assistant message (commentary)".to_string() + } + ("assistant", Some(MessagePhase::FinalAnswer)) => "Assistant message (final)".to_string(), + ("assistant", None) => "Assistant message".to_string(), + ("user", _) => "User message".to_string(), + ("system", _) => "System message".to_string(), + (role, _) => format!("{role} message"), + } +} + +fn section_for_message_role(role: &str) -> ContextSectionKind { + match role { + "developer" | "system" => ContextSectionKind::BuiltIn, + "user" | "assistant" => ContextSectionKind::Conversation, + _ => ContextSectionKind::Conversation, + } +} + +fn section_order(label: &str) -> usize { + match label { + "Built-in" => 0, + "AGENTS.md" => 1, + "Skills" => 2, + "Runtime context" => 3, + "Conversation" => 4, + _ => usize::MAX, + } +} + +impl ContextSectionKind { + fn label(self) -> &'static str { + match self { + ContextSectionKind::BuiltIn => "Built-in", + ContextSectionKind::Agents => "AGENTS.md", + ContextSectionKind::Skills => "Skills", + ContextSectionKind::Runtime => "Runtime context", + ContextSectionKind::Conversation => "Conversation", + } + } +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + fn user_text(text: &str) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: text.to_string(), + }], + end_turn: None, + phase: None, + } + } + + fn developer_text(text: &str) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: text.to_string(), + }], + end_turn: None, + phase: None, + } + } + + #[test] + fn groups_built_in_agents_skills_runtime_and_conversation_sections() { + let base_instructions = BaseInstructions { + text: "Base instructions".to_string(), + }; + let skill = SKILL_FRAGMENT.into_message(SKILL_FRAGMENT.wrap( + "notify\n/tmp/skills/notify/SKILL.md\nBody".to_string(), + )); + let breakdown = build_context_window_breakdown( + &[ + developer_text( + "\n### Available skills\n- notify: send a notification\n### How to use skills\n", + ), + user_text( + "# AGENTS.md instructions for /repo\n\n\nUse focused tests.\n", + ), + skill, + user_text("\n /repo\n"), + user_text("hello"), + ResponseItem::Reasoning { + id: "reasoning-1".to_string(), + summary: Vec::new(), + content: None, + encrypted_content: Some("a".repeat(1000)), + }, + ], + &base_instructions, + Some(1000), + /*verbose*/ false, + ); + + let mut section_labels: Vec = breakdown + .sections + .iter() + .map(|section| section.label.clone()) + .collect(); + section_labels.sort(); + assert_eq!( + section_labels, + vec![ + "AGENTS.md".to_string(), + "Built-in".to_string(), + "Conversation".to_string(), + "Runtime context".to_string(), + "Skills".to_string(), + ] + ); + assert_eq!( + breakdown.total_tokens, + breakdown + .sections + .iter() + .map(|section| section.tokens) + .sum::() + ); + let mut detail_labels = breakdown + .sections + .iter() + .flat_map(|section| section.details.iter()) + .map(|detail| detail.label.clone()) + .collect::>(); + detail_labels.sort(); + assert_eq!( + detail_labels, + vec![ + "AGENTS.md instructions for /repo".to_string(), + "Base instructions".to_string(), + "Environment context".to_string(), + "Implicit skills catalog (1 skills)".to_string(), + "Reasoning".to_string(), + "Skill: notify (/tmp/skills/notify/SKILL.md)".to_string(), + "User message".to_string(), + ] + ); + } + + #[test] + fn verbose_breakdown_keeps_repeated_rows_instead_of_merging() { + let breakdown = build_context_window_breakdown( + &[user_text("first"), user_text("second")], + &BaseInstructions { + text: String::new(), + }, + /*model_context_window*/ None, + /*verbose*/ true, + ); + + let conversation = breakdown + .sections + .iter() + .find(|section| section.label == "Conversation") + .expect("conversation section"); + + assert_eq!( + conversation + .details + .iter() + .map(|detail| detail.label.clone()) + .collect::>(), + vec!["User message".to_string(), "User message".to_string()] + ); + assert_eq!( + conversation + .details + .iter() + .map(|detail| detail.tokens) + .sum::(), + conversation.tokens + ); + } +} diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index ec2df30d3b..efa3ac2f70 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -1,4 +1,6 @@ use crate::codex::TurnContext; +use crate::context_manager::context_breakdown; +use crate::context_manager::context_breakdown::ContextWindowBreakdown; use crate::context_manager::normalize; use crate::event_mapping::has_non_contextual_dev_message_content; use crate::event_mapping::is_contextual_dev_message_content; @@ -153,6 +155,20 @@ impl ContextManager { Some(base_tokens.saturating_add(items_tokens)) } + pub(crate) fn get_context_window_breakdown( + &self, + base_instructions: &BaseInstructions, + model_context_window: Option, + verbose: bool, + ) -> ContextWindowBreakdown { + context_breakdown::build_context_window_breakdown( + &self.items, + base_instructions, + model_context_window, + verbose, + ) + } + pub(crate) fn remove_first_item(&mut self) { if !self.items.is_empty() { // Remove the oldest item (front of the list). Items are ordered from @@ -493,7 +509,7 @@ fn estimate_reasoning_length(encoded_len: usize) -> usize { .saturating_sub(650) } -fn estimate_item_token_count(item: &ResponseItem) -> i64 { +pub(super) fn estimate_item_token_count(item: &ResponseItem) -> i64 { let model_visible_bytes = estimate_response_item_model_visible_bytes(item); approx_tokens_from_byte_count_i64(model_visible_bytes) } diff --git a/codex-rs/core/src/context_manager/mod.rs b/codex-rs/core/src/context_manager/mod.rs index 853f8af5ac..6046aaab12 100644 --- a/codex-rs/core/src/context_manager/mod.rs +++ b/codex-rs/core/src/context_manager/mod.rs @@ -1,7 +1,11 @@ +mod context_breakdown; mod history; mod normalize; pub(crate) mod updates; +pub use context_breakdown::ContextWindowBreakdown; +pub use context_breakdown::ContextWindowDetail; +pub use context_breakdown::ContextWindowSection; pub(crate) use history::ContextManager; pub(crate) use history::TotalTokenUsageBreakdown; pub(crate) use history::estimate_response_item_model_visible_bytes; diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index d68c2b2992..8b0cfd5c11 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -26,6 +26,9 @@ pub mod config; pub mod config_loader; pub mod connectors; mod context_manager; +pub use context_manager::ContextWindowBreakdown; +pub use context_manager::ContextWindowDetail; +pub use context_manager::ContextWindowSection; mod contextual_user_message; mod environment_context; pub mod exec; diff --git a/codex-rs/docs/codex_mcp_interface.md b/codex-rs/docs/codex_mcp_interface.md index b4e3adfe53..fa617e7be6 100644 --- a/codex-rs/docs/codex_mcp_interface.md +++ b/codex-rs/docs/codex_mcp_interface.md @@ -13,7 +13,7 @@ Codex exposes MCP-compatible methods to manage threads, turns, accounts, config, At a glance: - Primary v2 RPCs - - `thread/start`, `thread/resume`, `thread/fork`, `thread/read`, `thread/list` + - `thread/start`, `thread/resume`, `thread/fork`, `thread/read`, `thread/context/read`, `thread/list` - `turn/start`, `turn/steer`, `turn/interrupt` - `account/read`, `account/login/start`, `account/login/cancel`, `account/logout`, `account/rateLimits/read` - `config/read`, `config/value/write`, `config/batchWrite` @@ -52,6 +52,8 @@ Use the separate `codex mcp` subcommand to manage configured MCP server launcher Use the v2 thread and turn APIs for all new integrations. `thread/start` creates a thread, `turn/start` submits user input, `turn/interrupt` stops an in-flight turn, and `thread/list` / `thread/read` expose persisted history. +Use `thread/context/read` for a live, approximate, sectioned breakdown of the loaded thread's current model-visible context window. + `getConversationSummary` remains as a compatibility helper for clients that still need a summary lookup by `conversationId` or `rolloutPath`. For complete request and response shapes, see the app-server README and the protocol definitions in `app-server-protocol/src/protocol/v2.rs`. diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4feefcac93..58531681e9 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -23,6 +23,7 @@ use crate::chatwidget::ChatWidget; use crate::chatwidget::ExternalEditorState; use crate::chatwidget::ReplayKind; use crate::chatwidget::ThreadInputState; +use crate::context_window; use crate::cwd_prompt::CwdPromptAction; use crate::diff_render::DiffSummary; use crate::exec_command::split_command_string; @@ -76,6 +77,8 @@ use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::SkillsListResponse; +use codex_app_server_protocol::ThreadContextReadResponse; +use codex_app_server_protocol::ThreadContextWindowBreakdown; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadLoadedListParams; use codex_app_server_protocol::ThreadRollbackResponse; @@ -1891,6 +1894,33 @@ impl App { }); } + fn fetch_context_window_breakdown( + &mut self, + app_server: &AppServerSession, + thread_id: ThreadId, + verbose: bool, + ) { + let request_handle = app_server.request_handle(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let result = request_handle + .request_typed(ClientRequest::ThreadContextRead { + request_id: RequestId::String(format!( + "thread-context-read-{}", + Uuid::new_v4() + )), + params: codex_app_server_protocol::ThreadContextReadParams { + thread_id: thread_id.to_string(), + verbose, + }, + }) + .await + .map(|response: ThreadContextReadResponse| response.context) + .map_err(|err| err.to_string()); + app_event_tx.send(AppEvent::ContextWindowBreakdownLoaded { result }); + }); + } + fn refresh_rate_limits(&mut self, app_server: &AppServerSession, request_id: u64) { let request_handle = app_server.request_handle(); let app_event_tx = self.app_event_tx.clone(); @@ -2129,6 +2159,25 @@ impl App { )); } + fn handle_context_window_breakdown_result( + &mut self, + result: Result, + ) { + self.chat_widget.clear_context_window_breakdown_loading(); + self.clear_committed_context_window_breakdown_loading(); + + match result { + Ok(context) => { + self.chat_widget + .add_to_history(context_window::new_context_window_output(&context)); + } + Err(err) => { + self.chat_widget + .add_error_message(format!("Failed to load context breakdown: {err}")); + } + } + } + fn clear_committed_mcp_inventory_loading(&mut self) { let Some(index) = self .transcript_cells @@ -2144,6 +2193,20 @@ impl App { } } + fn clear_committed_context_window_breakdown_loading(&mut self) { + let Some(index) = self.transcript_cells.iter().rposition(|cell| { + cell.as_any() + .is::() + }) else { + return; + }; + + self.transcript_cells.remove(index); + if let Some(Overlay::Transcript(overlay)) = &mut self.overlay { + overlay.replace_cells(self.transcript_cells.clone()); + } + } + /// Intercept composer-history operations and handle them locally against /// `$CODEX_HOME/history.jsonl`, bypassing the app-server RPC layer. async fn try_handle_local_history_op( @@ -4423,6 +4486,18 @@ impl App { AppEvent::McpInventoryLoaded { result } => { self.handle_mcp_inventory_result(result); } + AppEvent::FetchContextWindowBreakdown { verbose } => { + if let Some(thread_id) = self.chat_widget.thread_id() { + self.fetch_context_window_breakdown(app_server, thread_id, verbose); + } else { + self.handle_context_window_breakdown_result(Err( + "session is not initialized yet".to_string(), + )); + } + } + AppEvent::ContextWindowBreakdownLoaded { result } => { + self.handle_context_window_breakdown_result(result); + } AppEvent::StartFileSearch(query) => { self.file_search.on_user_query(query); } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index d727963f0d..a655522392 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -16,6 +16,7 @@ use codex_app_server_protocol::PluginListResponse; use codex_app_server_protocol::PluginReadParams; use codex_app_server_protocol::PluginReadResponse; use codex_app_server_protocol::PluginUninstallResponse; +use codex_app_server_protocol::ThreadContextWindowBreakdown; use codex_chatgpt::connectors::AppInfo; use codex_file_search::FileMatch; use codex_protocol::ThreadId; @@ -264,6 +265,16 @@ pub(crate) enum AppEvent { result: Result, String>, }, + /// Fetch the current thread's context-window breakdown via app-server. + FetchContextWindowBreakdown { + verbose: bool, + }, + + /// Result of fetching the current thread's context-window breakdown. + ContextWindowBreakdownLoaded { + result: Result, + }, + InsertHistoryCell(Box), /// Apply rollback semantics to local transcript cells. diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 34c0df7b4b..c481cbd7b1 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5266,6 +5266,9 @@ impl ChatWidget { ); } } + SlashCommand::Context => { + self.add_context_window_breakdown_output(/*verbose*/ false); + } SlashCommand::DebugConfig => { self.add_debug_config_output(); } @@ -5393,6 +5396,17 @@ impl ChatWidget { } } } + SlashCommand::Context => { + if trimmed.is_empty() { + self.dispatch_command(cmd); + return; + } + if trimmed.eq_ignore_ascii_case("verbose") { + self.add_context_window_breakdown_output(/*verbose*/ true); + } else { + self.add_error_message("Usage: /context [verbose]".to_string()); + } + } SlashCommand::Rename if !trimmed.is_empty() => { self.session_telemetry .counter("codex.thread.rename", /*inc*/ 1, &[]); @@ -9954,6 +9968,38 @@ impl ChatWidget { self.request_redraw(); } + /// Begin the asynchronous context-window breakdown flow. + /// + /// The spinner lives in `active_cell` and is cleared by + /// [`clear_context_window_breakdown_loading`] once the result arrives. + pub(crate) fn add_context_window_breakdown_output(&mut self, verbose: bool) { + self.flush_answer_stream_with_separator(); + self.flush_active_cell(); + self.active_cell = Some(Box::new(crate::context_window::new_context_window_loading( + self.config.animations, + ))); + self.bump_active_cell_revision(); + self.request_redraw(); + self.app_event_tx + .send(AppEvent::FetchContextWindowBreakdown { verbose }); + } + + /// Remove the `/context` loading spinner if it is still the active cell. + pub(crate) fn clear_context_window_breakdown_loading(&mut self) { + let Some(active) = self.active_cell.as_ref() else { + return; + }; + if !active + .as_any() + .is::() + { + return; + } + self.active_cell = None; + self.bump_active_cell_revision(); + self.request_redraw(); + } + pub(crate) fn add_connectors_output(&mut self) { if !self.connectors_enabled() { self.add_info_message( diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index c6fff59897..57bb4ced01 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -402,6 +402,55 @@ async fn slash_mcp_requests_inventory_via_app_server() { assert!(op_rx.try_recv().is_err(), "expected no core op to be sent"); } +#[tokio::test] +async fn slash_context_requests_default_breakdown_via_app_server() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.dispatch_command(SlashCommand::Context); + + assert!(active_blob(&chat).contains("Loading context breakdown")); + assert_matches!( + rx.try_recv(), + Ok(AppEvent::FetchContextWindowBreakdown { verbose: false }) + ); + assert!(op_rx.try_recv().is_err(), "expected no core op to be sent"); +} + +#[tokio::test] +async fn slash_context_verbose_requests_expanded_breakdown_via_app_server() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.dispatch_command_with_args(SlashCommand::Context, "verbose".to_string(), Vec::new()); + + assert_matches!( + rx.try_recv(), + Ok(AppEvent::FetchContextWindowBreakdown { verbose: true }) + ); + assert!(op_rx.try_recv().is_err(), "expected no core op to be sent"); +} + +#[tokio::test] +async fn slash_context_rejects_unknown_inline_args() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.dispatch_command_with_args(SlashCommand::Context, "everything".to_string(), Vec::new()); + + let event = rx + .try_recv() + .expect("expected /context usage error for unknown args"); + match event { + AppEvent::InsertHistoryCell(cell) => { + let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80)); + assert!( + rendered.contains("Usage: /context [verbose]"), + "expected /context usage error, got {rendered:?}" + ); + } + other => panic!("expected InsertHistoryCell error, got {other:?}"), + } + assert!(op_rx.try_recv().is_err(), "expected no core op to be sent"); +} + #[tokio::test] async fn slash_memory_update_reports_stubbed_feature() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/context_window.rs b/codex-rs/tui/src/context_window.rs new file mode 100644 index 0000000000..54eeebf4be --- /dev/null +++ b/codex-rs/tui/src/context_window.rs @@ -0,0 +1,448 @@ +use codex_app_server_protocol::ThreadContextWindowBreakdown; +use codex_app_server_protocol::ThreadContextWindowDetail; +use codex_app_server_protocol::ThreadContextWindowSection; +use ratatui::prelude::Line; +use ratatui::prelude::Span; +use ratatui::style::Style; +use ratatui::style::Stylize; +use std::cmp::Reverse; + +use crate::exec_cell::spinner; +use crate::history_cell::HistoryCell; +use crate::render::line_utils::line_to_static; +use crate::wrapping::RtOptions; +use crate::wrapping::adaptive_wrap_line; + +const CONTEXT_WINDOW_LABEL: &str = "/context"; +const CONTEXT_BAR_LABEL_WIDTH: usize = 6; +const MIN_BAR_WIDTH: usize = 18; +const MAX_BAR_WIDTH: usize = 44; +const SECTION_BAR_WIDTH: usize = 10; + +pub(crate) fn new_context_window_output( + context: &ThreadContextWindowBreakdown, +) -> ContextWindowOutputCell { + ContextWindowOutputCell { + context: context.clone(), + } +} + +#[derive(Debug)] +pub(crate) struct ContextWindowOutputCell { + context: ThreadContextWindowBreakdown, +} + +impl HistoryCell for ContextWindowOutputCell { + fn display_lines(&self, width: u16) -> Vec> { + render_context_window_output(&self.context, width) + } +} + +#[derive(Debug)] +pub(crate) struct ContextWindowLoadingCell { + created_at: std::time::Instant, + animations_enabled: bool, +} + +impl ContextWindowLoadingCell { + fn new(animations_enabled: bool) -> Self { + Self { + created_at: std::time::Instant::now(), + animations_enabled, + } + } +} + +impl HistoryCell for ContextWindowLoadingCell { + fn display_lines(&self, _width: u16) -> Vec> { + vec![ + CONTEXT_WINDOW_LABEL.magenta().into(), + "".into(), + vec![ + if self.animations_enabled { + spinner(Some(self.created_at), self.animations_enabled) + } else { + "•".into() + }, + " Loading context breakdown...".dim(), + ] + .into(), + ] + } + + fn transcript_animation_tick(&self) -> Option { + self.animations_enabled + .then(|| self.created_at.elapsed().as_millis() as u64 / 80) + } +} + +pub(crate) fn new_context_window_loading(animations_enabled: bool) -> ContextWindowLoadingCell { + ContextWindowLoadingCell::new(animations_enabled) +} + +fn format_context_summary(context: &ThreadContextWindowBreakdown) -> String { + match context.model_context_window { + Some(model_context_window) if model_context_window > 0 => { + let remaining = model_context_window + .saturating_sub(context.total_tokens) + .max(0); + let used_percent = percentage(context.total_tokens, model_context_window); + format!( + "{} used of {} ({used_percent:.1}%), {} remaining", + format_tokens(context.total_tokens), + format_tokens(model_context_window), + format_tokens(remaining), + ) + } + _ => format!("{} used", format_tokens(context.total_tokens)), + } +} + +fn render_context_window_output( + context: &ThreadContextWindowBreakdown, + width: u16, +) -> Vec> { + let bar_width = context_bar_width(width); + let mut sections = context.sections.iter().collect::>(); + sections.sort_by_key(|section| Reverse(section.tokens.max(0))); + + let mut lines = vec![ + CONTEXT_WINDOW_LABEL.magenta().into(), + "".into(), + "Context map".bold().into(), + format!(" {}.", format_context_summary(context)).into(), + render_context_window_bar( + "window", + §ions, + context + .model_context_window + .filter(|model_context_window| *model_context_window > 0) + .unwrap_or(context.total_tokens.max(0)), + bar_width, + ), + ]; + + if !sections.is_empty() { + lines.push(render_context_window_bar( + "used", + §ions, + context.total_tokens.max(0), + bar_width, + )); + lines.push("".into()); + lines.extend(render_context_window_legend(§ions, context, width)); + } + + if context.sections.is_empty() { + lines.push("".into()); + lines.push(" • No context rows found.".italic().into()); + return lines; + } + + let label_width = sections + .iter() + .map(|section| section.label.chars().count()) + .max() + .unwrap_or(0) + .clamp(8, 18); + for section in sections { + lines.push("".into()); + lines.push(render_section_header(section, context, label_width)); + if section.details.is_empty() { + lines.push(" • ".dim().into()); + continue; + } + for detail in §ion.details { + lines.extend(render_detail_lines( + detail, + section.tokens, + §ion.label, + width, + )); + } + } + + lines +} + +fn render_context_window_bar( + label: &str, + sections: &[&ThreadContextWindowSection], + scale_total: i64, + bar_width: usize, +) -> Line<'static> { + let section_tokens = sections + .iter() + .map(|section| section.tokens.max(0)) + .collect::>(); + let section_widths = allocate_token_widths(§ion_tokens, scale_total, bar_width); + + let mut spans = vec![ + format!(" {label: 0 { + spans.push(Span::styled( + "█".repeat(width), + section_style(§ion.label), + )); + } + } + + let used_width = spans + .iter() + .skip(1) + .map(|span| span.content.chars().count()) + .sum::(); + if used_width < bar_width { + spans.push("░".repeat(bar_width - used_width).dim()); + } + spans.push("▏".dim()); + spans.into() +} + +fn render_context_window_legend( + sections: &[&ThreadContextWindowSection], + context: &ThreadContextWindowBreakdown, + width: u16, +) -> Vec> { + let mut lines = Vec::new(); + for section in sections { + let mut spans = vec![" ".into(), section_marker(§ion.label), " ".into()]; + spans.push(Span::styled( + section.label.clone(), + section_style(§ion.label), + )); + if context.total_tokens > 0 { + spans.push(format!(" {:.1}%", percentage(section.tokens, context.total_tokens)).dim()); + } + lines.extend( + adaptive_wrap_line( + &Line::from(spans), + RtOptions::new(width as usize) + .initial_indent("".into()) + .subsequent_indent(" ".into()), + ) + .into_iter() + .map(|line| line_to_static(&line)), + ); + } + lines +} + +fn render_section_header( + section: &ThreadContextWindowSection, + context: &ThreadContextWindowBreakdown, + label_width: usize, +) -> Line<'static> { + let mut spans = vec![ + " ".into(), + section_marker(§ion.label), + " ".into(), + Span::styled( + format!("{:14}", format_tokens(section.tokens)).dim(), + ]; + if context.total_tokens > 0 { + spans.push( + format!( + " {:>5.1}% of used", + percentage(section.tokens, context.total_tokens) + ) + .dim(), + ); + } + spans.into() +} + +fn render_detail_lines( + detail: &ThreadContextWindowDetail, + section_tokens: i64, + section_label: &str, + width: u16, +) -> Vec> { + let active_width = + allocate_token_widths(&[detail.tokens], section_tokens.max(0), SECTION_BAR_WIDTH)[0]; + let line = Line::from(vec![ + " ".into(), + Span::styled("█".repeat(active_width), section_style(section_label)), + "░".repeat(SECTION_BAR_WIDTH - active_width).dim(), + " ".into(), + detail.label.clone().into(), + " ".into(), + format!("({})", format_tokens(detail.tokens)).dim(), + ]); + adaptive_wrap_line( + &line, + RtOptions::new(width as usize) + .initial_indent("".into()) + .subsequent_indent(" ".into()), + ) + .into_iter() + .map(|line| line_to_static(&line)) + .collect() +} + +fn context_bar_width(width: u16) -> usize { + usize::from(width.saturating_sub(10)).clamp(MIN_BAR_WIDTH, MAX_BAR_WIDTH) +} + +fn section_marker(label: &str) -> Span<'static> { + Span::styled("■", section_style(label)) +} + +fn section_style(label: &str) -> Style { + match label { + "Conversation" => Style::new().cyan().bold(), + "Skills" => Style::new().green().bold(), + "AGENTS.md" => Style::new().magenta().bold(), + "Runtime context" => Style::new().red().bold(), + "Built-in" => Style::new().fg(ratatui::style::Color::Yellow).bold(), + _ => Style::new(), + } +} + +fn allocate_token_widths(tokens: &[i64], total_tokens: i64, width: usize) -> Vec { + if tokens.is_empty() { + return Vec::new(); + } + if total_tokens <= 0 || width == 0 { + return vec![0; tokens.len()]; + } + + let total_tokens = total_tokens as u128; + let width = width as u128; + let represented_tokens = tokens + .iter() + .map(|tokens| tokens.max(&0)) + .map(|tokens| *tokens as u128) + .sum::() + .min(total_tokens); + let mut represented_width = (represented_tokens * width / total_tokens) as usize; + if represented_tokens > 0 && represented_width == 0 { + represented_width = 1; + } + + let mut allocations = Vec::with_capacity(tokens.len()); + let mut assigned_width = 0usize; + let mut remainders = Vec::new(); + + for (index, tokens) in tokens.iter().enumerate() { + let tokens = tokens.max(&0); + let scaled_width = (*tokens as u128) * width; + let cell_width = (scaled_width / total_tokens) as usize; + assigned_width = assigned_width.saturating_add(cell_width); + allocations.push(cell_width); + remainders.push((index, scaled_width % total_tokens, *tokens)); + } + + let mut remaining_width = represented_width.saturating_sub(assigned_width); + remainders + .sort_by_key(|(index, remainder, tokens)| (Reverse(*remainder), Reverse(*tokens), *index)); + for (index, _, tokens) in remainders { + if remaining_width == 0 || tokens <= 0 { + break; + } + allocations[index] = allocations[index].saturating_add(1); + remaining_width -= 1; + } + + allocations +} + +fn format_tokens(tokens: i64) -> String { + format!("~{} tokens", format_number(tokens.max(0))) +} + +fn percentage(numerator: i64, denominator: i64) -> f64 { + if denominator <= 0 { + return 0.0; + } + 100.0 * numerator.max(0) as f64 / denominator as f64 +} + +fn format_number(value: i64) -> String { + let mut digits = value.abs().to_string(); + let mut formatted = String::new(); + while digits.len() > 3 { + let tail = digits.split_off(digits.len() - 3); + if formatted.is_empty() { + formatted = tail; + } else { + formatted = format!("{tail},{formatted}"); + } + } + if formatted.is_empty() { + formatted = digits; + } else if !digits.is_empty() { + formatted = format!("{digits},{formatted}"); + } + if value < 0 { + format!("-{formatted}") + } else { + formatted + } +} + +#[cfg(test)] +mod tests { + use insta::assert_snapshot; + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn renders_default_context_window_breakdown() { + let cell = new_context_window_output(&ThreadContextWindowBreakdown { + model_context_window: Some(200_000), + total_tokens: 20_000, + sections: vec![ + ThreadContextWindowSection { + label: "Skills".to_string(), + tokens: 12_000, + details: vec![ThreadContextWindowDetail { + label: "Implicit skills catalog (42 skills)".to_string(), + tokens: 12_000, + }], + }, + ThreadContextWindowSection { + label: "Conversation".to_string(), + tokens: 8_000, + details: vec![ + ThreadContextWindowDetail { + label: "Tool output".to_string(), + tokens: 5_000, + }, + ThreadContextWindowDetail { + label: "User message".to_string(), + tokens: 3_000, + }, + ], + }, + ], + }); + + assert_snapshot!(render_lines(&cell.display_lines(/*width*/ 80))); + } + + #[test] + fn allocates_bar_width_by_largest_remainder() { + assert_eq!(allocate_token_widths(&[5, 3, 2], 10, 7), vec![4, 2, 1]); + } + + fn render_lines(lines: &[Line<'static>]) -> String { + let mut rendered = Vec::new(); + for line in lines { + let mut text = String::new(); + for span in &line.spans { + text.push_str(&span.content); + } + rendered.push(text); + } + rendered.join("\n") + } +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 991c91c550..0b40da4676 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -102,6 +102,7 @@ mod clipboard_paste; mod clipboard_text; mod collaboration_modes; mod color; +mod context_window; pub mod custom_terminal; mod cwd_prompt; mod debug_config; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index ec624d3fb9..ebcc46d3d3 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -37,6 +37,7 @@ pub enum SlashCommand { Copy, Mention, Status, + Context, DebugConfig, Title, Statusline, @@ -86,6 +87,7 @@ impl SlashCommand { SlashCommand::Mention => "mention a file", SlashCommand::Skills => "use skills to improve how Codex performs specific tasks", SlashCommand::Status => "show current session configuration and token usage", + SlashCommand::Context => "show a semantic breakdown of current context-window usage", SlashCommand::DebugConfig => "show config layers and requirement sources for debugging", SlashCommand::Title => "configure which items appear in the terminal title", SlashCommand::Statusline => "configure which items appear in the status line", @@ -132,6 +134,7 @@ impl SlashCommand { | SlashCommand::Rename | SlashCommand::Plan | SlashCommand::Fast + | SlashCommand::Context | SlashCommand::SandboxReadRoot ) } @@ -165,6 +168,7 @@ impl SlashCommand { | SlashCommand::Mention | SlashCommand::Skills | SlashCommand::Status + | SlashCommand::Context | SlashCommand::DebugConfig | SlashCommand::Ps | SlashCommand::Stop diff --git a/codex-rs/tui/src/snapshots/codex_tui__context_window__tests__renders_default_context_window_breakdown.snap b/codex-rs/tui/src/snapshots/codex_tui__context_window__tests__renders_default_context_window_breakdown.snap new file mode 100644 index 0000000000..0398111b45 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__context_window__tests__renders_default_context_window_breakdown.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/context_window.rs +assertion_line: 429 +expression: render_lines(&cell.display_lines(80)) +--- +/context + +Context map + ~20,000 tokens used of ~200,000 tokens (10.0%), ~180,000 tokens remaining. + window▕████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▏ + used ▕████████████████████████████████████████████▏ + + ■ Skills 60.0% + ■ Conversation 40.0% + + ■ Skills ~12,000 tokens 60.0% of used + ██████████ Implicit skills catalog (42 skills) (~12,000 tokens) + + ■ Conversation ~8,000 tokens 40.0% of used + ██████░░░░ Tool output (~5,000 tokens) + ███░░░░░░░ User message (~3,000 tokens) diff --git a/codex-rs/tui/tooltips.txt b/codex-rs/tui/tooltips.txt index 88f9de01cd..b3c8cbdee8 100644 --- a/codex-rs/tui/tooltips.txt +++ b/codex-rs/tui/tooltips.txt @@ -6,6 +6,7 @@ Use /permissions to control when Codex asks for confirmation. Run /review to get a code review of your current changes. Use /skills to list available skills or ask Codex to use one. Use /status to see the current model, approvals, and token usage. +Use /context to inspect what the current context window is made of. Use /statusline to configure which items appear in the status line. Use /fork to branch the current chat into a new thread. Use /init to create an AGENTS.md with project-specific guidance.