Compare commits

...

1 Commits

Author SHA1 Message Date
starr-openai
7b63b539a0 Add /context window breakdown 2026-04-02 14:02:03 -07:00
35 changed files with 2138 additions and 3 deletions

View File

@@ -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": {

View File

@@ -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": {
@@ -12335,6 +12359,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.",

View File

@@ -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": {
@@ -10190,6 +10214,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.",

View File

@@ -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"
}

View File

@@ -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"
}

File diff suppressed because one or more lines are too long

View File

@@ -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, };

View File

@@ -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, };

View File

@@ -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<ThreadContextWindowSection>, };

View File

@@ -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, };

View File

@@ -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<ThreadContextWindowDetail>, };

View File

@@ -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";

View File

@@ -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,

View File

@@ -3151,6 +3151,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<i64>,
pub total_tokens: i64,
pub sections: Vec<ThreadContextWindowSection>,
}
#[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<ThreadContextWindowDetail>,
}
#[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/")]

View File

@@ -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 threads status changes (`threadId` + new `status`).
- `thread/archive` — move a threads 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.

View File

@@ -118,6 +118,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;
@@ -771,6 +776,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;
@@ -3574,6 +3583,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<ThreadId> {
self.thread_manager.subscribe_thread_created()
}

View File

@@ -58,6 +58,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;
@@ -447,6 +448,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<i64> {
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,

View File

@@ -30,6 +30,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;

View File

@@ -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::<ThreadStartResponse>(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::<ThreadContextReadResponse>(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<_>>(),
vec!["Built-in"]
);
assert_eq!(
context.sections[0]
.details
.iter()
.map(|detail| detail.label.as_str())
.collect::<Vec<_>>(),
vec!["Base instructions"]
);
assert_eq!(
context.sections[0].tokens,
context.sections[0]
.details
.iter()
.map(|detail| detail.tokens)
.sum::<i64>()
);
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
"#
),
)
}

View File

@@ -178,6 +178,7 @@ use crate::config::resolve_web_search_mode_for_turn;
use crate::config::types::McpServerConfig;
use crate::config::types::ShellEnvironmentPolicy;
use crate::context_manager::ContextManager;
use crate::context_manager::ContextWindowBreakdown;
use crate::context_manager::TotalTokenUsageBreakdown;
use crate::environment_context::EnvironmentContext;
use crate::error::CodexErr;
@@ -2138,6 +2139,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<TokenUsage> {
let state = self.state.lock().await;
state.token_info().map(|info| info.total_token_usage)

View File

@@ -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::error::CodexErr;
use crate::error::Result as CodexResult;
use crate::file_watcher::WatchRegistration;
@@ -130,6 +131,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 {

View File

@@ -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<i64>,
pub total_tokens: i64,
pub sections: Vec<ContextWindowSection>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContextWindowSection {
pub label: String,
pub tokens: i64,
pub details: Vec<ContextWindowDetail>,
}
#[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<ContextWindowSection>,
}
#[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<i64>,
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<ContextWindowSection> {
let mut sections: Vec<ContextWindowSection> = [
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(&section_order(&right.label)))
.then(left.label.cmp(&right.label))
});
sections
}
}
fn classify_message_content(
id: Option<&String>,
role: &str,
content: &[ContentItem],
end_turn: Option<bool>,
phase: Option<&MessagePhase>,
) -> Vec<DetailAllocation> {
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, "<permissions instructions>") {
return (
ContextSectionKind::BuiltIn,
"Permission instructions".to_string(),
);
}
if starts_with_tag(trimmed, "<model_switch>") {
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, "<personality_spec>") {
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<bool>,
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!("</{tag_name}>");
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(
"<name>notify</name>\n<path>/tmp/skills/notify/SKILL.md</path>\nBody".to_string(),
));
let breakdown = build_context_window_breakdown(
&[
developer_text(
"<skills_instructions>\n### Available skills\n- notify: send a notification\n### How to use skills\n</skills_instructions>",
),
user_text(
"# AGENTS.md instructions for /repo\n\n<INSTRUCTIONS>\nUse focused tests.\n</INSTRUCTIONS>",
),
skill,
user_text("<environment_context>\n <cwd>/repo</cwd>\n</environment_context>"),
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<String> = 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::<i64>()
);
let mut detail_labels = breakdown
.sections
.iter()
.flat_map(|section| section.details.iter())
.map(|detail| detail.label.clone())
.collect::<Vec<_>>();
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<_>>(),
vec!["User message".to_string(), "User message".to_string()]
);
assert_eq!(
conversation
.details
.iter()
.map(|detail| detail.tokens)
.sum::<i64>(),
conversation.tokens
);
}
}

View File

@@ -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<i64>,
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)
}

View File

@@ -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;

View File

@@ -29,6 +29,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;
pub use codex_utils_path::env;
mod environment_context;

View File

@@ -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`.

View File

@@ -22,6 +22,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;
@@ -75,6 +76,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;
@@ -1880,6 +1883,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();
@@ -2118,6 +2148,25 @@ impl App {
));
}
fn handle_context_window_breakdown_result(
&mut self,
result: Result<ThreadContextWindowBreakdown, String>,
) {
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
@@ -2133,6 +2182,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::<context_window::ContextWindowLoadingCell>()
}) 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(
@@ -4372,6 +4435,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);
}

View File

@@ -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<Vec<McpServerStatus>, 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<ThreadContextWindowBreakdown, String>,
},
InsertHistoryCell(Box<dyn HistoryCell>),
/// Apply rollback semantics to local transcript cells.

View File

@@ -5279,6 +5279,9 @@ impl ChatWidget {
);
}
}
SlashCommand::Context => {
self.add_context_window_breakdown_output(/*verbose*/ false);
}
SlashCommand::DebugConfig => {
self.add_debug_config_output();
}
@@ -5406,6 +5409,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, &[]);
@@ -9967,6 +9981,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::<crate::context_window::ContextWindowLoadingCell>()
{
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(

View File

@@ -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;

View File

@@ -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<Line<'static>> {
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<Line<'static>> {
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<u64> {
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<Line<'static>> {
let bar_width = context_bar_width(width);
let mut sections = context.sections.iter().collect::<Vec<_>>();
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",
&sections,
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",
&sections,
context.total_tokens.max(0),
bar_width,
));
lines.push("".into());
lines.extend(render_context_window_legend(&sections, 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(" • <none>".dim().into());
continue;
}
for detail in &section.details {
lines.extend(render_detail_lines(
detail,
section.tokens,
&section.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::<Vec<_>>();
let section_widths = allocate_token_widths(&section_tokens, scale_total, bar_width);
let mut spans = vec![
format!(" {label:<CONTEXT_BAR_LABEL_WIDTH$}").dim(),
"".dim(),
];
for (section, width) in sections.iter().zip(section_widths) {
if width > 0 {
spans.push(Span::styled(
"".repeat(width),
section_style(&section.label),
));
}
}
let used_width = spans
.iter()
.skip(1)
.map(|span| span.content.chars().count())
.sum::<usize>();
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<Line<'static>> {
let mut lines = Vec::new();
for section in sections {
let mut spans = vec![" ".into(), section_marker(&section.label), " ".into()];
spans.push(Span::styled(
section.label.clone(),
section_style(&section.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(&section.label),
" ".into(),
Span::styled(
format!("{:<label_width$}", section.label),
section_style(&section.label),
),
" ".into(),
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<Line<'static>> {
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<usize> {
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::<u128>()
.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")
}
}

View File

@@ -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;

View File

@@ -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

View File

@@ -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)

View File

@@ -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.