mirror of
https://github.com/openai/codex.git
synced 2026-04-15 02:04:46 +00:00
Compare commits
1 Commits
dev/shaqay
...
starr/cont
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b63b539a0 |
@@ -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": {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -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, };
|
||||
@@ -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, };
|
||||
@@ -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>, };
|
||||
@@ -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, };
|
||||
@@ -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>, };
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/")]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
141
codex-rs/app-server/tests/suite/v2/thread_context_read.rs
Normal file
141
codex-rs/app-server/tests/suite/v2/thread_context_read.rs
Normal 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
|
||||
"#
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
731
codex-rs/core/src/context_manager/context_breakdown.rs
Normal file
731
codex-rs/core/src/context_manager/context_breakdown.rs
Normal 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(§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<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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
448
codex-rs/tui/src/context_window.rs
Normal file
448
codex-rs/tui/src/context_window.rs
Normal 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",
|
||||
§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(" • <none>".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::<Vec<_>>();
|
||||
let section_widths = allocate_token_widths(§ion_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(§ion.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(§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!("{:<label_width$}", section.label),
|
||||
section_style(§ion.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")
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user