Compare commits

..

2 Commits

Author SHA1 Message Date
Matthew Zeng
45a414b16a update 2026-01-26 12:28:04 -08:00
Matthew Zeng
1f0621d129 update 2026-01-26 10:12:05 -08:00
57 changed files with 181 additions and 1212 deletions

View File

@@ -303,9 +303,7 @@ jobs:
cp "$dest/$base" "$bundle_dir/$base"
cp "$runner_src" "$bundle_dir/codex-command-runner.exe"
cp "$setup_src" "$bundle_dir/codex-windows-sandbox-setup.exe"
# Use an absolute path so bundle zips land in the real dist
# dir even when 7z runs from a temp directory.
(cd "$bundle_dir" && 7z a "$(pwd)/$dest/${base}.zip" .)
(cd "$bundle_dir" && 7z a "$dest/${base}.zip" .)
else
echo "warning: missing sandbox binaries; falling back to single-binary zip"
echo "warning: expected $runner_src and $setup_src"

1
codex-rs/Cargo.lock generated
View File

@@ -2006,7 +2006,6 @@ dependencies = [
"chrono",
"codex-protocol",
"codex-utils-absolute-path",
"codex-utils-string",
"dirs-next",
"dunce",
"pretty_assertions",

View File

@@ -15,8 +15,8 @@ You can also install via Homebrew (`brew install --cask codex`) or download a pl
## Documentation quickstart
- First run with Codex? Start with the [Getting Started guide](https://developers.openai.com/codex) (links to the walkthrough for prompts, keyboard shortcuts, and session management).
- Want deeper control? See [Configuration documentation](https://developers.openai.com/codex/config-advanced/).
- First run with Codex? Start with [`docs/getting-started.md`](../docs/getting-started.md) (links to the walkthrough for prompts, keyboard shortcuts, and session management).
- Want deeper control? See [`docs/config.md`](../docs/config.md) and [`docs/install.md`](../docs/install.md).
## What's new in the Rust CLI
@@ -24,13 +24,13 @@ The Rust implementation is now the maintained Codex CLI and serves as the defaul
### Config
Codex supports a rich set of configuration options. Note that the Rust CLI uses `config.toml` instead of `config.json`. See [Configuration documentation](https://developers.openai.com/codex/config-advanced/) for details.
Codex supports a rich set of configuration options. Note that the Rust CLI uses `config.toml` instead of `config.json`. See [`docs/config.md`](../docs/config.md) for details.
### Model Context Protocol Support
#### MCP client
Codex CLI functions as an MCP client that allows the Codex CLI and IDE extension to connect to MCP servers on startup. See the [configuration documentation](https://developers.openai.com/codex/config-advanced/) for details.
Codex CLI functions as an MCP client that allows the Codex CLI and IDE extension to connect to MCP servers on startup. See the [`configuration documentation`](../docs/config.md#connecting-to-mcp-servers) for details.
#### MCP server (experimental)
@@ -46,7 +46,7 @@ Use `codex mcp` to add/list/get/remove MCP server launchers defined in `config.t
### Notifications
You can enable notifications by configuring a script that is run whenever the agent finishes a turn. The [notify documentation](https://developers.openai.com/codex/config-advanced/#notifications) includes a detailed example that explains how to get desktop notifications via [terminal-notifier](https://github.com/julienXX/terminal-notifier) on macOS. When Codex detects that it is running under WSL 2 inside Windows Terminal (`WT_SESSION` is set), the TUI automatically falls back to native Windows toast notifications so approval prompts and completed turns surface even though Windows Terminal does not implement OSC 9.
You can enable notifications by configuring a script that is run whenever the agent finishes a turn. The [notify documentation](../docs/config.md#notify) includes a detailed example that explains how to get desktop notifications via [terminal-notifier](https://github.com/julienXX/terminal-notifier) on macOS. When Codex detects that it is running under WSL 2 inside Windows Terminal (`WT_SESSION` is set), the TUI automatically falls back to native Windows toast notifications so approval prompts and completed turns surface even though Windows Terminal does not implement OSC 9.
### `codex exec` to run Codex programmatically/non-interactively

View File

@@ -117,10 +117,6 @@ client_request_definitions! {
params: v2::ThreadArchiveParams,
response: v2::ThreadArchiveResponse,
},
ThreadUnarchive => "thread/unarchive" {
params: v2::ThreadUnarchiveParams,
response: v2::ThreadUnarchiveResponse,
},
ThreadRollback => "thread/rollback" {
params: v2::ThreadRollbackParams,
response: v2::ThreadRollbackResponse,

View File

@@ -1001,6 +1001,7 @@ pub struct AppInfo {
pub name: String,
pub description: Option<String>,
pub logo_url: Option<String>,
pub logo_url_dark: Option<String>,
pub install_url: Option<String>,
#[serde(default)]
pub is_accessible: bool,
@@ -1223,20 +1224,6 @@ pub struct ThreadArchiveParams {
#[ts(export_to = "v2/")]
pub struct ThreadArchiveResponse {}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadUnarchiveParams {
pub thread_id: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadUnarchiveResponse {
pub thread: Thread,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -1274,32 +1261,11 @@ pub struct ThreadListParams {
/// Optional provider filter; when set, only sessions recorded under these
/// providers are returned. When present but empty, includes all providers.
pub model_providers: Option<Vec<String>>,
/// Optional source filter; when set, only sessions from these source kinds
/// are returned. When omitted or empty, defaults to interactive sources.
pub source_kinds: Option<Vec<ThreadSourceKind>>,
/// Optional archived filter; when set to true, only archived threads are returned.
/// If false or null, only non-archived threads are returned.
pub archived: Option<bool>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase", export_to = "v2/")]
pub enum ThreadSourceKind {
Cli,
#[serde(rename = "vscode")]
#[ts(rename = "vscode")]
VsCode,
Exec,
AppServer,
SubAgent,
SubAgentReview,
SubAgentCompact,
SubAgentThreadSpawn,
SubAgentOther,
Unknown,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export_to = "v2/")]

View File

@@ -81,7 +81,6 @@ Example (from OpenAI's official VSCode extension):
- `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`.
- `thread/archive` — move a threads rollout file into the archived directory; returns `{}` on success.
- `thread/unarchive` — move an archived rollout file back into the sessions directory; returns the restored `thread` on success.
- `thread/rollback` — drop the last N turns from the agents in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success.
- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications.
- `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`.
@@ -167,7 +166,6 @@ To branch from a stored session, call `thread/fork` with the `thread.id`. This c
- `limit` — server defaults to a reasonable page size if unset.
- `sortKey``created_at` (default) or `updated_at`.
- `modelProviders` — restrict results to specific providers; unset, null, or an empty array will include all providers.
- `sourceKinds` — restrict results to specific sources; omit or pass `[]` for interactive sessions only (`cli`, `vscode`).
- `archived` — when `true`, list archived threads only. When `false` or `null`, list non-archived threads (default).
Example:
@@ -225,15 +223,6 @@ Use `thread/archive` to move the persisted rollout (stored as a JSONL file on di
An archived thread will not appear in `thread/list` unless `archived` is set to `true`.
### Example: Unarchive a thread
Use `thread/unarchive` to move an archived rollout back into the sessions directory.
```json
{ "method": "thread/unarchive", "id": 24, "params": { "threadId": "thr_b" } }
{ "id": 24, "result": { "thread": { "id": "thr_b" } } }
```
### Example: Start a turn (send user input)
Turns attach user input (text or images) to a thread and trigger Codex generation. The `input` field is a list of discriminated unions:

View File

@@ -111,12 +111,9 @@ use codex_app_server_protocol::ThreadResumeParams;
use codex_app_server_protocol::ThreadResumeResponse;
use codex_app_server_protocol::ThreadRollbackParams;
use codex_app_server_protocol::ThreadSortKey;
use codex_app_server_protocol::ThreadSourceKind;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::ThreadStartedNotification;
use codex_app_server_protocol::ThreadUnarchiveParams;
use codex_app_server_protocol::ThreadUnarchiveResponse;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnError;
use codex_app_server_protocol::TurnInterruptParams;
@@ -133,6 +130,7 @@ use codex_chatgpt::connectors;
use codex_core::AuthManager;
use codex_core::CodexThread;
use codex_core::Cursor as RolloutCursor;
use codex_core::INTERACTIVE_SESSION_SOURCES;
use codex_core::InitialHistory;
use codex_core::NewThread;
use codex_core::RolloutRecorder;
@@ -153,7 +151,6 @@ use codex_core::error::CodexErr;
use codex_core::exec::ExecParams;
use codex_core::exec_env::create_env;
use codex_core::features::Feature;
use codex_core::find_archived_thread_path_by_id_str;
use codex_core::find_thread_path_by_id_str;
use codex_core::git_info::git_diff_to_remote;
use codex_core::mcp::collect_mcp_snapshot;
@@ -167,7 +164,6 @@ use codex_core::protocol::ReviewTarget as CoreReviewTarget;
use codex_core::protocol::SessionConfiguredEvent;
use codex_core::read_head_for_summary;
use codex_core::read_session_meta_line;
use codex_core::rollout_date_parts;
use codex_core::sandboxing::SandboxPermissions;
use codex_feedback::CodexFeedback;
use codex_login::ServerOptions as LoginServerOptions;
@@ -209,9 +205,6 @@ use tracing::info;
use tracing::warn;
use uuid::Uuid;
use crate::filters::compute_source_filters;
use crate::filters::source_kind_matches;
type PendingInterruptQueue = Vec<(RequestId, ApiVersion)>;
pub(crate) type PendingInterrupts = Arc<Mutex<HashMap<ThreadId, PendingInterruptQueue>>>;
@@ -411,9 +404,6 @@ impl CodexMessageProcessor {
ClientRequest::ThreadArchive { request_id, params } => {
self.thread_archive(request_id, params).await;
}
ClientRequest::ThreadUnarchive { request_id, params } => {
self.thread_unarchive(request_id, params).await;
}
ClientRequest::ThreadRollback { request_id, params } => {
self.thread_rollback(request_id, params).await;
}
@@ -1653,150 +1643,6 @@ impl CodexMessageProcessor {
}
}
async fn thread_unarchive(&mut self, request_id: RequestId, params: ThreadUnarchiveParams) {
let thread_id = match ThreadId::from_string(&params.thread_id) {
Ok(id) => id,
Err(err) => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("invalid thread id: {err}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
};
let archived_path = match find_archived_thread_path_by_id_str(
&self.config.codex_home,
&thread_id.to_string(),
)
.await
{
Ok(Some(path)) => path,
Ok(None) => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("no archived rollout found for thread id {thread_id}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
Err(err) => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("failed to locate archived thread id {thread_id}: {err}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
};
let rollout_path_display = archived_path.display().to_string();
let fallback_provider = self.config.model_provider_id.clone();
let archived_folder = self
.config
.codex_home
.join(codex_core::ARCHIVED_SESSIONS_SUBDIR);
let result: Result<Thread, JSONRPCErrorError> = async {
let canonical_archived_dir = tokio::fs::canonicalize(&archived_folder).await.map_err(
|err| JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!(
"failed to unarchive thread: unable to resolve archived directory: {err}"
),
data: None,
},
)?;
let canonical_rollout_path = tokio::fs::canonicalize(&archived_path).await;
let canonical_rollout_path = if let Ok(path) = canonical_rollout_path
&& path.starts_with(&canonical_archived_dir)
{
path
} else {
return Err(JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!(
"rollout path `{rollout_path_display}` must be in archived directory"
),
data: None,
});
};
let required_suffix = format!("{thread_id}.jsonl");
let Some(file_name) = canonical_rollout_path.file_name().map(OsStr::to_owned) else {
return Err(JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("rollout path `{rollout_path_display}` missing file name"),
data: None,
});
};
if !file_name
.to_string_lossy()
.ends_with(required_suffix.as_str())
{
return Err(JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!(
"rollout path `{rollout_path_display}` does not match thread id {thread_id}"
),
data: None,
});
}
let Some((year, month, day)) = rollout_date_parts(&file_name) else {
return Err(JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!(
"rollout path `{rollout_path_display}` missing filename timestamp"
),
data: None,
});
};
let sessions_folder = self.config.codex_home.join(codex_core::SESSIONS_SUBDIR);
let dest_dir = sessions_folder.join(year).join(month).join(day);
let restored_path = dest_dir.join(&file_name);
tokio::fs::create_dir_all(&dest_dir)
.await
.map_err(|err| JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to unarchive thread: {err}"),
data: None,
})?;
tokio::fs::rename(&canonical_rollout_path, &restored_path)
.await
.map_err(|err| JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to unarchive thread: {err}"),
data: None,
})?;
let summary =
read_summary_from_rollout(restored_path.as_path(), fallback_provider.as_str())
.await
.map_err(|err| JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to read unarchived thread: {err}"),
data: None,
})?;
Ok(summary_to_thread(summary))
}
.await;
match result {
Ok(thread) => {
let response = ThreadUnarchiveResponse { thread };
self.outgoing.send_response(request_id, response).await;
}
Err(err) => {
self.outgoing.send_error(request_id, err).await;
}
}
}
async fn thread_rollback(&mut self, request_id: RequestId, params: ThreadRollbackParams) {
let ThreadRollbackParams {
thread_id,
@@ -1848,7 +1694,6 @@ impl CodexMessageProcessor {
limit,
sort_key,
model_providers,
source_kinds,
archived,
} = params;
@@ -1865,7 +1710,6 @@ impl CodexMessageProcessor {
requested_page_size,
cursor,
model_providers,
source_kinds,
core_sort_key,
archived.unwrap_or(false),
)
@@ -2543,7 +2387,6 @@ impl CodexMessageProcessor {
requested_page_size,
cursor,
model_providers,
None,
CoreThreadSortKey::UpdatedAt,
false,
)
@@ -2564,7 +2407,6 @@ impl CodexMessageProcessor {
requested_page_size: usize,
cursor: Option<String>,
model_providers: Option<Vec<String>>,
source_kinds: Option<Vec<ThreadSourceKind>>,
sort_key: CoreThreadSortKey,
archived: bool,
) -> Result<(Vec<ConversationSummary>, Option<String>), JSONRPCErrorError> {
@@ -2594,8 +2436,6 @@ impl CodexMessageProcessor {
None => Some(vec![self.config.model_provider_id.clone()]),
};
let fallback_provider = self.config.model_provider_id.clone();
let (allowed_sources_vec, source_kind_filter) = compute_source_filters(source_kinds);
let allowed_sources = allowed_sources_vec.as_slice();
while remaining > 0 {
let page_size = remaining.min(THREAD_LIST_MAX_LIMIT);
@@ -2605,7 +2445,7 @@ impl CodexMessageProcessor {
page_size,
cursor_obj.as_ref(),
sort_key,
allowed_sources,
INTERACTIVE_SESSION_SOURCES,
model_provider_filter.as_deref(),
fallback_provider.as_str(),
)
@@ -2621,7 +2461,7 @@ impl CodexMessageProcessor {
page_size,
cursor_obj.as_ref(),
sort_key,
allowed_sources,
INTERACTIVE_SESSION_SOURCES,
model_provider_filter.as_deref(),
fallback_provider.as_str(),
)
@@ -2650,11 +2490,6 @@ impl CodexMessageProcessor {
updated_at,
)
})
.filter(|summary| {
source_kind_filter
.as_ref()
.is_none_or(|filter| source_kind_matches(&summary.source, filter))
})
.collect::<Vec<_>>();
if filtered.len() > remaining {
filtered.truncate(remaining);
@@ -2865,8 +2700,6 @@ impl CodexMessageProcessor {
}
};
let scopes = scopes.or_else(|| server.scopes.clone());
match perform_oauth_login_return_url(
&name,
&url,
@@ -3756,6 +3589,7 @@ impl CodexMessageProcessor {
name: connector.connector_name,
description: connector.connector_description,
logo_url: connector.logo_url,
logo_url_dark: connector.logo_url_dark,
install_url: connector.install_url,
is_accessible: connector.is_accessible,
})

View File

@@ -1,155 +0,0 @@
use codex_app_server_protocol::ThreadSourceKind;
use codex_core::INTERACTIVE_SESSION_SOURCES;
use codex_protocol::protocol::SessionSource as CoreSessionSource;
use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource;
pub(crate) fn compute_source_filters(
source_kinds: Option<Vec<ThreadSourceKind>>,
) -> (Vec<CoreSessionSource>, Option<Vec<ThreadSourceKind>>) {
let Some(source_kinds) = source_kinds else {
return (INTERACTIVE_SESSION_SOURCES.to_vec(), None);
};
if source_kinds.is_empty() {
return (INTERACTIVE_SESSION_SOURCES.to_vec(), None);
}
let requires_post_filter = source_kinds.iter().any(|kind| {
matches!(
kind,
ThreadSourceKind::Exec
| ThreadSourceKind::AppServer
| ThreadSourceKind::SubAgent
| ThreadSourceKind::SubAgentReview
| ThreadSourceKind::SubAgentCompact
| ThreadSourceKind::SubAgentThreadSpawn
| ThreadSourceKind::SubAgentOther
| ThreadSourceKind::Unknown
)
});
if requires_post_filter {
(Vec::new(), Some(source_kinds))
} else {
let interactive_sources = source_kinds
.iter()
.filter_map(|kind| match kind {
ThreadSourceKind::Cli => Some(CoreSessionSource::Cli),
ThreadSourceKind::VsCode => Some(CoreSessionSource::VSCode),
ThreadSourceKind::Exec
| ThreadSourceKind::AppServer
| ThreadSourceKind::SubAgent
| ThreadSourceKind::SubAgentReview
| ThreadSourceKind::SubAgentCompact
| ThreadSourceKind::SubAgentThreadSpawn
| ThreadSourceKind::SubAgentOther
| ThreadSourceKind::Unknown => None,
})
.collect::<Vec<_>>();
(interactive_sources, Some(source_kinds))
}
}
pub(crate) fn source_kind_matches(source: &CoreSessionSource, filter: &[ThreadSourceKind]) -> bool {
filter.iter().any(|kind| match kind {
ThreadSourceKind::Cli => matches!(source, CoreSessionSource::Cli),
ThreadSourceKind::VsCode => matches!(source, CoreSessionSource::VSCode),
ThreadSourceKind::Exec => matches!(source, CoreSessionSource::Exec),
ThreadSourceKind::AppServer => matches!(source, CoreSessionSource::Mcp),
ThreadSourceKind::SubAgent => matches!(source, CoreSessionSource::SubAgent(_)),
ThreadSourceKind::SubAgentReview => {
matches!(
source,
CoreSessionSource::SubAgent(CoreSubAgentSource::Review)
)
}
ThreadSourceKind::SubAgentCompact => {
matches!(
source,
CoreSessionSource::SubAgent(CoreSubAgentSource::Compact)
)
}
ThreadSourceKind::SubAgentThreadSpawn => matches!(
source,
CoreSessionSource::SubAgent(CoreSubAgentSource::ThreadSpawn { .. })
),
ThreadSourceKind::SubAgentOther => matches!(
source,
CoreSessionSource::SubAgent(CoreSubAgentSource::Other(_))
),
ThreadSourceKind::Unknown => matches!(source, CoreSessionSource::Unknown),
})
}
#[cfg(test)]
mod tests {
use super::*;
use codex_protocol::ThreadId;
use pretty_assertions::assert_eq;
use uuid::Uuid;
#[test]
fn compute_source_filters_defaults_to_interactive_sources() {
let (allowed_sources, filter) = compute_source_filters(None);
assert_eq!(allowed_sources, INTERACTIVE_SESSION_SOURCES.to_vec());
assert_eq!(filter, None);
}
#[test]
fn compute_source_filters_empty_means_interactive_sources() {
let (allowed_sources, filter) = compute_source_filters(Some(Vec::new()));
assert_eq!(allowed_sources, INTERACTIVE_SESSION_SOURCES.to_vec());
assert_eq!(filter, None);
}
#[test]
fn compute_source_filters_interactive_only_skips_post_filtering() {
let source_kinds = vec![ThreadSourceKind::Cli, ThreadSourceKind::VsCode];
let (allowed_sources, filter) = compute_source_filters(Some(source_kinds.clone()));
assert_eq!(
allowed_sources,
vec![CoreSessionSource::Cli, CoreSessionSource::VSCode]
);
assert_eq!(filter, Some(source_kinds));
}
#[test]
fn compute_source_filters_subagent_variant_requires_post_filtering() {
let source_kinds = vec![ThreadSourceKind::SubAgentReview];
let (allowed_sources, filter) = compute_source_filters(Some(source_kinds.clone()));
assert_eq!(allowed_sources, Vec::new());
assert_eq!(filter, Some(source_kinds));
}
#[test]
fn source_kind_matches_distinguishes_subagent_variants() {
let parent_thread_id =
ThreadId::from_string(&Uuid::new_v4().to_string()).expect("valid thread id");
let review = CoreSessionSource::SubAgent(CoreSubAgentSource::Review);
let spawn = CoreSessionSource::SubAgent(CoreSubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
});
assert!(source_kind_matches(
&review,
&[ThreadSourceKind::SubAgentReview]
));
assert!(!source_kind_matches(
&review,
&[ThreadSourceKind::SubAgentThreadSpawn]
));
assert!(source_kind_matches(
&spawn,
&[ThreadSourceKind::SubAgentThreadSpawn]
));
assert!(!source_kind_matches(
&spawn,
&[ThreadSourceKind::SubAgentReview]
));
}
}

View File

@@ -42,7 +42,6 @@ mod codex_message_processor;
mod config_api;
mod dynamic_tools;
mod error_code;
mod filters;
mod fuzzy_file_search;
mod message_processor;
mod models;

View File

@@ -30,7 +30,6 @@ pub use responses::create_final_assistant_message_sse_response;
pub use responses::create_request_user_input_sse_response;
pub use responses::create_shell_command_sse_response;
pub use rollout::create_fake_rollout;
pub use rollout::create_fake_rollout_with_source;
pub use rollout::create_fake_rollout_with_text_elements;
pub use rollout::rollout_path;
use serde::de::DeserializeOwned;

View File

@@ -53,7 +53,6 @@ use codex_app_server_protocol::ThreadReadParams;
use codex_app_server_protocol::ThreadResumeParams;
use codex_app_server_protocol::ThreadRollbackParams;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadUnarchiveParams;
use codex_app_server_protocol::TurnInterruptParams;
use codex_app_server_protocol::TurnStartParams;
use codex_core::default_client::CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR;
@@ -366,15 +365,6 @@ impl McpProcess {
self.send_request("thread/archive", params).await
}
/// Send a `thread/unarchive` JSON-RPC request.
pub async fn send_thread_unarchive_request(
&mut self,
params: ThreadUnarchiveParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("thread/unarchive", params).await
}
/// Send a `thread/rollback` JSON-RPC request.
pub async fn send_thread_rollback_request(
&mut self,

View File

@@ -38,27 +38,6 @@ pub fn create_fake_rollout(
preview: &str,
model_provider: Option<&str>,
git_info: Option<GitInfo>,
) -> Result<String> {
create_fake_rollout_with_source(
codex_home,
filename_ts,
meta_rfc3339,
preview,
model_provider,
git_info,
SessionSource::Cli,
)
}
/// Create a minimal rollout file with an explicit session source.
pub fn create_fake_rollout_with_source(
codex_home: &Path,
filename_ts: &str,
meta_rfc3339: &str,
preview: &str,
model_provider: Option<&str>,
git_info: Option<GitInfo>,
source: SessionSource,
) -> Result<String> {
let uuid = Uuid::new_v4();
let uuid_str = uuid.to_string();
@@ -78,7 +57,7 @@ pub fn create_fake_rollout_with_source(
cwd: PathBuf::from("/"),
originator: "codex".to_string(),
cli_version: "0.0.0".to_string(),
source,
source: SessionSource::Cli,
model_provider: model_provider.map(str::to_string),
base_instructions: None,
};

View File

@@ -76,6 +76,7 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> {
connector_name: "Alpha".to_string(),
connector_description: Some("Alpha connector".to_string()),
logo_url: Some("https://example.com/alpha.png".to_string()),
logo_url_dark: Some("https://example.com/alpha-dark.png".to_string()),
install_url: None,
is_accessible: false,
},
@@ -84,6 +85,7 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> {
connector_name: "beta".to_string(),
connector_description: None,
logo_url: None,
logo_url_dark: None,
install_url: None,
is_accessible: false,
},
@@ -127,6 +129,7 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> {
name: "Beta App".to_string(),
description: None,
logo_url: None,
logo_url_dark: None,
install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()),
is_accessible: true,
},
@@ -135,6 +138,7 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> {
name: "Alpha".to_string(),
description: Some("Alpha connector".to_string()),
logo_url: Some("https://example.com/alpha.png".to_string()),
logo_url_dark: Some("https://example.com/alpha-dark.png".to_string()),
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
is_accessible: false,
},
@@ -155,6 +159,7 @@ async fn list_apps_paginates_results() -> Result<()> {
connector_name: "Alpha".to_string(),
connector_description: Some("Alpha connector".to_string()),
logo_url: None,
logo_url_dark: None,
install_url: None,
is_accessible: false,
},
@@ -163,6 +168,7 @@ async fn list_apps_paginates_results() -> Result<()> {
connector_name: "beta".to_string(),
connector_description: None,
logo_url: None,
logo_url_dark: None,
install_url: None,
is_accessible: false,
},
@@ -206,6 +212,7 @@ async fn list_apps_paginates_results() -> Result<()> {
name: "Beta App".to_string(),
description: None,
logo_url: None,
logo_url_dark: None,
install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()),
is_accessible: true,
}];
@@ -234,6 +241,7 @@ async fn list_apps_paginates_results() -> Result<()> {
name: "Alpha".to_string(),
description: Some("Alpha connector".to_string()),
logo_url: None,
logo_url_dark: None,
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
is_accessible: false,
}];

View File

@@ -18,6 +18,5 @@ mod thread_read;
mod thread_resume;
mod thread_rollback;
mod thread_start;
mod thread_unarchive;
mod turn_interrupt;
mod turn_start;

View File

@@ -1,7 +1,6 @@
use anyhow::Result;
use app_test_support::McpProcess;
use app_test_support::create_fake_rollout;
use app_test_support::create_fake_rollout_with_source;
use app_test_support::rollout_path;
use app_test_support::to_response;
use chrono::DateTime;
@@ -13,12 +12,8 @@ use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SessionSource;
use codex_app_server_protocol::ThreadListResponse;
use codex_app_server_protocol::ThreadSortKey;
use codex_app_server_protocol::ThreadSourceKind;
use codex_core::ARCHIVED_SESSIONS_SUBDIR;
use codex_protocol::ThreadId;
use codex_protocol::protocol::GitInfo as CoreGitInfo;
use codex_protocol::protocol::SessionSource as CoreSessionSource;
use codex_protocol::protocol::SubAgentSource;
use pretty_assertions::assert_eq;
use std::cmp::Reverse;
use std::fs;
@@ -43,10 +38,9 @@ async fn list_threads(
cursor: Option<String>,
limit: Option<u32>,
providers: Option<Vec<String>>,
source_kinds: Option<Vec<ThreadSourceKind>>,
archived: Option<bool>,
) -> Result<ThreadListResponse> {
list_threads_with_sort(mcp, cursor, limit, providers, source_kinds, None, archived).await
list_threads_with_sort(mcp, cursor, limit, providers, None, archived).await
}
async fn list_threads_with_sort(
@@ -54,7 +48,6 @@ async fn list_threads_with_sort(
cursor: Option<String>,
limit: Option<u32>,
providers: Option<Vec<String>>,
source_kinds: Option<Vec<ThreadSourceKind>>,
sort_key: Option<ThreadSortKey>,
archived: Option<bool>,
) -> Result<ThreadListResponse> {
@@ -64,7 +57,6 @@ async fn list_threads_with_sort(
limit,
sort_key,
model_providers: providers,
source_kinds,
archived,
})
.await?;
@@ -139,7 +131,6 @@ async fn thread_list_basic_empty() -> Result<()> {
Some(10),
Some(vec!["mock_provider".to_string()]),
None,
None,
)
.await?;
assert!(data.is_empty());
@@ -203,7 +194,6 @@ async fn thread_list_pagination_next_cursor_none_on_last_page() -> Result<()> {
Some(2),
Some(vec!["mock_provider".to_string()]),
None,
None,
)
.await?;
assert_eq!(data1.len(), 2);
@@ -229,7 +219,6 @@ async fn thread_list_pagination_next_cursor_none_on_last_page() -> Result<()> {
Some(2),
Some(vec!["mock_provider".to_string()]),
None,
None,
)
.await?;
assert!(data2.len() <= 2);
@@ -280,7 +269,6 @@ async fn thread_list_respects_provider_filter() -> Result<()> {
Some(10),
Some(vec!["other_provider".to_string()]),
None,
None,
)
.await?;
assert_eq!(data.len(), 1);
@@ -299,207 +287,6 @@ async fn thread_list_respects_provider_filter() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn thread_list_empty_source_kinds_defaults_to_interactive_only() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
let cli_id = create_fake_rollout(
codex_home.path(),
"2025-02-01T10-00-00",
"2025-02-01T10:00:00Z",
"CLI",
Some("mock_provider"),
None,
)?;
let exec_id = create_fake_rollout_with_source(
codex_home.path(),
"2025-02-01T11-00-00",
"2025-02-01T11:00:00Z",
"Exec",
Some("mock_provider"),
None,
CoreSessionSource::Exec,
)?;
let mut mcp = init_mcp(codex_home.path()).await?;
let ThreadListResponse { data, next_cursor } = list_threads(
&mut mcp,
None,
Some(10),
Some(vec!["mock_provider".to_string()]),
Some(Vec::new()),
None,
)
.await?;
assert_eq!(next_cursor, None);
let ids: Vec<_> = data.iter().map(|thread| thread.id.as_str()).collect();
assert_eq!(ids, vec![cli_id.as_str()]);
assert_ne!(cli_id, exec_id);
assert_eq!(data[0].source, SessionSource::Cli);
Ok(())
}
#[tokio::test]
async fn thread_list_filters_by_source_kind_subagent_thread_spawn() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
let cli_id = create_fake_rollout(
codex_home.path(),
"2025-02-01T10-00-00",
"2025-02-01T10:00:00Z",
"CLI",
Some("mock_provider"),
None,
)?;
let parent_thread_id = ThreadId::from_string(&Uuid::new_v4().to_string())?;
let subagent_id = create_fake_rollout_with_source(
codex_home.path(),
"2025-02-01T11-00-00",
"2025-02-01T11:00:00Z",
"SubAgent",
Some("mock_provider"),
None,
CoreSessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
}),
)?;
let mut mcp = init_mcp(codex_home.path()).await?;
let ThreadListResponse { data, next_cursor } = list_threads(
&mut mcp,
None,
Some(10),
Some(vec!["mock_provider".to_string()]),
Some(vec![ThreadSourceKind::SubAgentThreadSpawn]),
None,
)
.await?;
assert_eq!(next_cursor, None);
let ids: Vec<_> = data.iter().map(|thread| thread.id.as_str()).collect();
assert_eq!(ids, vec![subagent_id.as_str()]);
assert_ne!(cli_id, subagent_id);
assert!(matches!(data[0].source, SessionSource::SubAgent(_)));
Ok(())
}
#[tokio::test]
async fn thread_list_filters_by_subagent_variant() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
let parent_thread_id = ThreadId::from_string(&Uuid::new_v4().to_string())?;
let review_id = create_fake_rollout_with_source(
codex_home.path(),
"2025-02-02T09-00-00",
"2025-02-02T09:00:00Z",
"Review",
Some("mock_provider"),
None,
CoreSessionSource::SubAgent(SubAgentSource::Review),
)?;
let compact_id = create_fake_rollout_with_source(
codex_home.path(),
"2025-02-02T10-00-00",
"2025-02-02T10:00:00Z",
"Compact",
Some("mock_provider"),
None,
CoreSessionSource::SubAgent(SubAgentSource::Compact),
)?;
let spawn_id = create_fake_rollout_with_source(
codex_home.path(),
"2025-02-02T11-00-00",
"2025-02-02T11:00:00Z",
"Spawn",
Some("mock_provider"),
None,
CoreSessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
}),
)?;
let other_id = create_fake_rollout_with_source(
codex_home.path(),
"2025-02-02T12-00-00",
"2025-02-02T12:00:00Z",
"Other",
Some("mock_provider"),
None,
CoreSessionSource::SubAgent(SubAgentSource::Other("custom".to_string())),
)?;
let mut mcp = init_mcp(codex_home.path()).await?;
let review = list_threads(
&mut mcp,
None,
Some(10),
Some(vec!["mock_provider".to_string()]),
Some(vec![ThreadSourceKind::SubAgentReview]),
None,
)
.await?;
let review_ids: Vec<_> = review
.data
.iter()
.map(|thread| thread.id.as_str())
.collect();
assert_eq!(review_ids, vec![review_id.as_str()]);
let compact = list_threads(
&mut mcp,
None,
Some(10),
Some(vec!["mock_provider".to_string()]),
Some(vec![ThreadSourceKind::SubAgentCompact]),
None,
)
.await?;
let compact_ids: Vec<_> = compact
.data
.iter()
.map(|thread| thread.id.as_str())
.collect();
assert_eq!(compact_ids, vec![compact_id.as_str()]);
let spawn = list_threads(
&mut mcp,
None,
Some(10),
Some(vec!["mock_provider".to_string()]),
Some(vec![ThreadSourceKind::SubAgentThreadSpawn]),
None,
)
.await?;
let spawn_ids: Vec<_> = spawn.data.iter().map(|thread| thread.id.as_str()).collect();
assert_eq!(spawn_ids, vec![spawn_id.as_str()]);
let other = list_threads(
&mut mcp,
None,
Some(10),
Some(vec!["mock_provider".to_string()]),
Some(vec![ThreadSourceKind::SubAgentOther]),
None,
)
.await?;
let other_ids: Vec<_> = other.data.iter().map(|thread| thread.id.as_str()).collect();
assert_eq!(other_ids, vec![other_id.as_str()]);
Ok(())
}
#[tokio::test]
async fn thread_list_fetches_until_limit_or_exhausted() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -532,7 +319,6 @@ async fn thread_list_fetches_until_limit_or_exhausted() -> Result<()> {
Some(8),
Some(vec!["target_provider".to_string()]),
None,
None,
)
.await?;
assert_eq!(
@@ -578,7 +364,6 @@ async fn thread_list_enforces_max_limit() -> Result<()> {
Some(200),
Some(vec!["mock_provider".to_string()]),
None,
None,
)
.await?;
assert_eq!(
@@ -625,7 +410,6 @@ async fn thread_list_stops_when_not_enough_filtered_results_exist() -> Result<()
Some(10),
Some(vec!["target_provider".to_string()]),
None,
None,
)
.await?;
assert_eq!(
@@ -673,7 +457,6 @@ async fn thread_list_includes_git_info() -> Result<()> {
Some(10),
Some(vec!["mock_provider".to_string()]),
None,
None,
)
.await?;
let thread = data
@@ -733,7 +516,6 @@ async fn thread_list_default_sorts_by_created_at() -> Result<()> {
Some(vec!["mock_provider".to_string()]),
None,
None,
None,
)
.await?;
@@ -793,7 +575,6 @@ async fn thread_list_sort_updated_at_orders_by_mtime() -> Result<()> {
None,
Some(10),
Some(vec!["mock_provider".to_string()]),
None,
Some(ThreadSortKey::UpdatedAt),
None,
)
@@ -858,7 +639,6 @@ async fn thread_list_updated_at_paginates_with_cursor() -> Result<()> {
None,
Some(2),
Some(vec!["mock_provider".to_string()]),
None,
Some(ThreadSortKey::UpdatedAt),
None,
)
@@ -875,7 +655,6 @@ async fn thread_list_updated_at_paginates_with_cursor() -> Result<()> {
Some(cursor1),
Some(2),
Some(vec!["mock_provider".to_string()]),
None,
Some(ThreadSortKey::UpdatedAt),
None,
)
@@ -917,7 +696,6 @@ async fn thread_list_created_at_tie_breaks_by_uuid() -> Result<()> {
Some(10),
Some(vec!["mock_provider".to_string()]),
None,
None,
)
.await?;
@@ -969,7 +747,6 @@ async fn thread_list_updated_at_tie_breaks_by_uuid() -> Result<()> {
None,
Some(10),
Some(vec!["mock_provider".to_string()]),
None,
Some(ThreadSortKey::UpdatedAt),
None,
)
@@ -1010,7 +787,6 @@ async fn thread_list_updated_at_uses_mtime() -> Result<()> {
None,
Some(10),
Some(vec!["mock_provider".to_string()]),
None,
Some(ThreadSortKey::UpdatedAt),
None,
)
@@ -1070,7 +846,6 @@ async fn thread_list_archived_filter() -> Result<()> {
Some(10),
Some(vec!["mock_provider".to_string()]),
None,
None,
)
.await?;
assert_eq!(data.len(), 1);
@@ -1081,7 +856,6 @@ async fn thread_list_archived_filter() -> Result<()> {
None,
Some(10),
Some(vec!["mock_provider".to_string()]),
None,
Some(true),
)
.await?;
@@ -1104,7 +878,6 @@ async fn thread_list_invalid_cursor_returns_error() -> Result<()> {
limit: Some(2),
sort_key: None,
model_providers: Some(vec!["mock_provider".to_string()]),
source_kinds: None,
archived: None,
})
.await?;

View File

@@ -1,101 +0,0 @@
use anyhow::Result;
use app_test_support::McpProcess;
use app_test_support::to_response;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadArchiveParams;
use codex_app_server_protocol::ThreadArchiveResponse;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::ThreadUnarchiveParams;
use codex_app_server_protocol::ThreadUnarchiveResponse;
use codex_core::find_archived_thread_path_by_id_str;
use codex_core::find_thread_path_by_id_str;
use std::path::Path;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
#[tokio::test]
async fn thread_unarchive_moves_rollout_back_into_sessions_directory() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path())?;
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 rollout_path = find_thread_path_by_id_str(codex_home.path(), &thread.id)
.await?
.expect("expected rollout path for thread id to exist");
let archive_id = mcp
.send_thread_archive_request(ThreadArchiveParams {
thread_id: thread.id.clone(),
})
.await?;
let archive_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(archive_id)),
)
.await??;
let _: ThreadArchiveResponse = to_response::<ThreadArchiveResponse>(archive_resp)?;
let archived_path = find_archived_thread_path_by_id_str(codex_home.path(), &thread.id)
.await?
.expect("expected archived rollout path for thread id to exist");
let archived_path_display = archived_path.display();
assert!(
archived_path.exists(),
"expected {archived_path_display} to exist"
);
let unarchive_id = mcp
.send_thread_unarchive_request(ThreadUnarchiveParams {
thread_id: thread.id.clone(),
})
.await?;
let unarchive_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(unarchive_id)),
)
.await??;
let _: ThreadUnarchiveResponse = to_response::<ThreadUnarchiveResponse>(unarchive_resp)?;
let rollout_path_display = rollout_path.display();
assert!(
rollout_path.exists(),
"expected rollout path {rollout_path_display} to be restored"
);
assert!(
!archived_path.exists(),
"expected archived rollout path {archived_path_display} to be moved"
);
Ok(())
}
fn create_config_toml(codex_home: &Path) -> std::io::Result<()> {
let config_toml = codex_home.join("config.toml");
std::fs::write(config_toml, config_contents())
}
fn config_contents() -> &'static str {
r#"model = "mock-model"
approval_policy = "never"
sandbox_mode = "read-only"
"#
}

View File

@@ -453,8 +453,8 @@ enum FeaturesSubcommand {
fn stage_str(stage: codex_core::features::Stage) -> &'static str {
use codex_core::features::Stage;
match stage {
Stage::UnderDevelopment => "under development",
Stage::Experimental { .. } => "experimental",
Stage::Beta => "experimental",
Stage::Experimental { .. } => "beta",
Stage::Stable => "stable",
Stage::Deprecated => "deprecated",
Stage::Removed => "removed",

View File

@@ -247,7 +247,6 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
};
servers.insert(name.clone(), new_entry);
@@ -349,11 +348,6 @@ async fn run_login(config_overrides: &CliConfigOverrides, login_args: LoginArgs)
_ => bail!("OAuth login is only supported for streamable HTTP servers."),
};
let mut scopes = scopes;
if scopes.is_empty() {
scopes = server.scopes.clone().unwrap_or_default();
}
perform_oauth_login(
&name,
&url,

View File

@@ -2,4 +2,5 @@
This file has moved. Please see the latest configuration documentation here:
- Configuration documentation: https://developers.openai.com/codex/config-advanced/
- Full config docs: [docs/config.md](../docs/config.md)
- MCP servers section: [docs/config.md#connecting-to-mcp-servers](../docs/config.md#connecting-to-mcp-servers)

View File

@@ -750,13 +750,6 @@
},
"type": "object"
},
"scopes": {
"default": null,
"items": {
"type": "string"
},
"type": "array"
},
"startup_timeout_ms": {
"default": null,
"format": "uint64",

View File

@@ -625,13 +625,11 @@ fn build_api_prompt(prompt: &Prompt, instructions: String, tools_json: Vec<Value
}
}
fn experimental_feature_headers(config: &Config) -> ApiHeaderMap {
fn beta_feature_headers(config: &Config) -> ApiHeaderMap {
let enabled = FEATURES
.iter()
.filter_map(|spec| {
if spec.stage.experimental_menu_description().is_some()
&& config.features.enabled(spec.id)
{
if spec.stage.beta_menu_description().is_some() && config.features.enabled(spec.id) {
Some(spec.key)
} else {
None
@@ -652,7 +650,7 @@ fn build_responses_headers(
config: &Config,
turn_state: Option<&Arc<OnceLock<String>>>,
) -> ApiHeaderMap {
let mut headers = experimental_feature_headers(config);
let mut headers = beta_feature_headers(config);
headers.insert(
WEB_SEARCH_ELIGIBLE_HEADER,
HeaderValue::from_static(

View File

@@ -732,7 +732,7 @@ impl Session {
None
} else {
Some(format!(
"Enable it with `--enable {canonical}` or `[features].{canonical}` in config.toml. See https://developers.openai.com/codex/config-advanced/ for details."
"Enable it with `--enable {canonical}` or `[features].{canonical}` in config.toml. See https://github.com/openai/codex/blob/main/docs/config.md#feature-flags for details."
))
};
post_session_configured_events.push(Event {

View File

@@ -167,11 +167,6 @@ mod document_helpers {
{
entry["disabled_tools"] = array_from_iter(disabled_tools.iter().cloned());
}
if let Some(scopes) = &config.scopes
&& !scopes.is_empty()
{
entry["scopes"] = array_from_iter(scopes.iter().cloned());
}
entry
}
@@ -1378,7 +1373,6 @@ gpt-5 = "gpt-5.1"
tool_timeout_sec: None,
enabled_tools: Some(vec!["one".to_string(), "two".to_string()]),
disabled_tools: None,
scopes: None,
},
);
@@ -1401,7 +1395,6 @@ gpt-5 = "gpt-5.1"
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: Some(vec!["forbidden".to_string()]),
scopes: None,
},
);
@@ -1467,7 +1460,6 @@ foo = { command = "cmd" }
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
);
@@ -1512,7 +1504,6 @@ foo = { command = "cmd" } # keep me
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
);
@@ -1556,7 +1547,6 @@ foo = { command = "cmd", args = ["--flag"] } # keep me
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
);
@@ -1601,7 +1591,6 @@ foo = { command = "cmd" }
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
);

View File

@@ -1772,7 +1772,6 @@ mod tests {
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
}
}
@@ -1790,7 +1789,6 @@ mod tests {
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
}
}
@@ -2616,7 +2614,6 @@ profile = "project"
tool_timeout_sec: Some(Duration::from_secs(5)),
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
);
@@ -2771,7 +2768,6 @@ bearer_token = "secret"
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
)]);
@@ -2841,7 +2837,6 @@ ZIG_VAR = "3"
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
)]);
@@ -2891,7 +2886,6 @@ ZIG_VAR = "3"
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
)]);
@@ -2939,7 +2933,6 @@ ZIG_VAR = "3"
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
)]);
@@ -3003,7 +2996,6 @@ startup_timeout_sec = 2.0
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
)]);
apply_blocking(
@@ -3079,7 +3071,6 @@ X-Auth = "DOCS_AUTH"
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
)]);
@@ -3108,7 +3099,6 @@ X-Auth = "DOCS_AUTH"
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
);
apply_blocking(
@@ -3175,7 +3165,6 @@ url = "https://example.com/mcp"
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
),
(
@@ -3194,7 +3183,6 @@ url = "https://example.com/mcp"
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
),
]);
@@ -3276,7 +3264,6 @@ url = "https://example.com/mcp"
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
)]);
@@ -3320,7 +3307,6 @@ url = "https://example.com/mcp"
tool_timeout_sec: None,
enabled_tools: Some(vec!["allowed".to_string()]),
disabled_tools: Some(vec!["blocked".to_string()]),
scopes: None,
},
)]);

View File

@@ -73,10 +73,6 @@ pub struct McpServerConfig {
/// Explicit deny-list of tools. These tools will be removed after applying `enabled_tools`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disabled_tools: Option<Vec<String>>,
/// Optional OAuth scopes to request during MCP login.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scopes: Option<Vec<String>>,
}
// Raw MCP config shape used for deserialization and JSON Schema generation.
@@ -117,8 +113,6 @@ pub(crate) struct RawMcpServerConfig {
pub enabled_tools: Option<Vec<String>>,
#[serde(default)]
pub disabled_tools: Option<Vec<String>>,
#[serde(default)]
pub scopes: Option<Vec<String>>,
}
impl<'de> Deserialize<'de> for McpServerConfig {
@@ -140,7 +134,6 @@ impl<'de> Deserialize<'de> for McpServerConfig {
let enabled = raw.enabled.unwrap_or_else(default_enabled);
let enabled_tools = raw.enabled_tools.clone();
let disabled_tools = raw.disabled_tools.clone();
let scopes = raw.scopes.clone();
fn throw_if_set<E, T>(transport: &str, field: &str, value: Option<&T>) -> Result<(), E>
where
@@ -195,7 +188,6 @@ impl<'de> Deserialize<'de> for McpServerConfig {
disabled_reason: None,
enabled_tools,
disabled_tools,
scopes,
})
}
}

View File

@@ -27,6 +27,8 @@ pub struct ConnectorInfo {
pub connector_description: Option<String>,
#[serde(default, rename = "logo_url")]
pub logo_url: Option<String>,
#[serde(default, rename = "logo_url_dark")]
pub logo_url_dark: Option<String>,
#[serde(default, rename = "install_url")]
pub install_url: Option<String>,
#[serde(default)]
@@ -133,6 +135,9 @@ pub fn merge_connectors(
if existing.logo_url.is_none() && connector.logo_url.is_some() {
existing.logo_url = connector.logo_url;
}
if existing.logo_url_dark.is_none() && connector.logo_url_dark.is_some() {
existing.logo_url_dark = connector.logo_url_dark;
}
} else {
merged.insert(connector_id, connector);
}
@@ -180,6 +185,7 @@ where
connector_name,
connector_description: None,
logo_url: None,
logo_url_dark: None,
is_accessible: true,
})
.collect();

View File

@@ -21,8 +21,8 @@ pub(crate) use legacy::legacy_feature_keys;
/// High-level lifecycle stage for a feature.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Stage {
/// Features that are still under development, not ready for external use
UnderDevelopment,
/// Closed beta features to be used while developing or within the company.
Beta,
/// Experimental features made available to users through the `/experimental` menu
Experimental {
name: &'static str,
@@ -38,14 +38,14 @@ pub enum Stage {
}
impl Stage {
pub fn experimental_menu_name(self) -> Option<&'static str> {
pub fn beta_menu_name(self) -> Option<&'static str> {
match self {
Stage::Experimental { name, .. } => Some(name),
_ => None,
}
}
pub fn experimental_menu_description(self) -> Option<&'static str> {
pub fn beta_menu_description(self) -> Option<&'static str> {
match self {
Stage::Experimental {
menu_description, ..
@@ -54,7 +54,7 @@ impl Stage {
}
}
pub fn experimental_announcement(self) -> Option<&'static str> {
pub fn beta_announcement(self) -> Option<&'static str> {
match self {
Stage::Experimental { announcement, .. } => Some(announcement),
_ => None,
@@ -343,10 +343,10 @@ pub const FEATURES: &[FeatureSpec] = &[
FeatureSpec {
id: Feature::WebSearchCached,
key: "web_search_cached",
stage: Stage::UnderDevelopment,
stage: Stage::Beta,
default_enabled: false,
},
// Experimental program. Rendered in the `/experimental` menu for users.
// Beta program. Rendered in the `/experimental` menu for users.
FeatureSpec {
id: Feature::UnifiedExec,
key: "unified_exec",
@@ -370,43 +370,43 @@ pub const FEATURES: &[FeatureSpec] = &[
FeatureSpec {
id: Feature::ChildAgentsMd,
key: "child_agents_md",
stage: Stage::UnderDevelopment,
stage: Stage::Beta,
default_enabled: false,
},
FeatureSpec {
id: Feature::ApplyPatchFreeform,
key: "apply_patch_freeform",
stage: Stage::UnderDevelopment,
stage: Stage::Beta,
default_enabled: false,
},
FeatureSpec {
id: Feature::ExecPolicy,
key: "exec_policy",
stage: Stage::UnderDevelopment,
stage: Stage::Beta,
default_enabled: true,
},
FeatureSpec {
id: Feature::WindowsSandbox,
key: "experimental_windows_sandbox",
stage: Stage::UnderDevelopment,
stage: Stage::Beta,
default_enabled: false,
},
FeatureSpec {
id: Feature::WindowsSandboxElevated,
key: "elevated_windows_sandbox",
stage: Stage::UnderDevelopment,
stage: Stage::Beta,
default_enabled: false,
},
FeatureSpec {
id: Feature::RemoteCompaction,
key: "remote_compaction",
stage: Stage::UnderDevelopment,
stage: Stage::Beta,
default_enabled: true,
},
FeatureSpec {
id: Feature::RemoteModels,
key: "remote_models",
stage: Stage::UnderDevelopment,
stage: Stage::Beta,
default_enabled: true,
},
FeatureSpec {
@@ -421,26 +421,26 @@ pub const FEATURES: &[FeatureSpec] = &[
#[cfg(windows)]
default_enabled: true,
#[cfg(not(windows))]
stage: Stage::UnderDevelopment,
stage: Stage::Beta,
#[cfg(not(windows))]
default_enabled: false,
},
FeatureSpec {
id: Feature::EnableRequestCompression,
key: "enable_request_compression",
stage: Stage::UnderDevelopment,
stage: Stage::Beta,
default_enabled: false,
},
FeatureSpec {
id: Feature::Collab,
key: "collab",
stage: Stage::UnderDevelopment,
stage: Stage::Beta,
default_enabled: false,
},
FeatureSpec {
id: Feature::Connectors,
key: "connectors",
stage: Stage::UnderDevelopment,
stage: Stage::Beta,
default_enabled: false,
},
FeatureSpec {
@@ -456,13 +456,13 @@ pub const FEATURES: &[FeatureSpec] = &[
FeatureSpec {
id: Feature::CollaborationModes,
key: "collaboration_modes",
stage: Stage::UnderDevelopment,
stage: Stage::Beta,
default_enabled: false,
},
FeatureSpec {
id: Feature::ResponsesWebsockets,
key: "responses_websockets",
stage: Stage::UnderDevelopment,
stage: Stage::Beta,
default_enabled: false,
},
];

View File

@@ -98,7 +98,6 @@ pub use rollout::INTERACTIVE_SESSION_SOURCES;
pub use rollout::RolloutRecorder;
pub use rollout::SESSIONS_SUBDIR;
pub use rollout::SessionMeta;
pub use rollout::find_archived_thread_path_by_id_str;
#[deprecated(note = "use find_thread_path_by_id_str")]
pub use rollout::find_conversation_path_by_id_str;
pub use rollout::find_thread_path_by_id_str;
@@ -109,7 +108,6 @@ pub use rollout::list::ThreadsPage;
pub use rollout::list::parse_cursor;
pub use rollout::list::read_head_for_summary;
pub use rollout::list::read_session_meta_line;
pub use rollout::rollout_date_parts;
mod function_tool;
mod state;
mod tasks;

View File

@@ -97,7 +97,6 @@ fn codex_apps_mcp_server_config(config: &Config, auth: Option<&CodexAuth>) -> Mc
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
}
}

View File

@@ -1182,7 +1182,6 @@ mod tests {
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
auth_status: McpAuthStatus::Unsupported,
};
@@ -1228,7 +1227,6 @@ mod tests {
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
auth_status: McpAuthStatus::Unsupported,
};

View File

@@ -1,5 +1,4 @@
use std::cmp::Reverse;
use std::ffi::OsStr;
use std::io::{self};
use std::num::NonZero;
use std::ops::ControlFlow;
@@ -16,7 +15,6 @@ use time::format_description::well_known::Rfc3339;
use time::macros::format_description;
use uuid::Uuid;
use super::ARCHIVED_SESSIONS_SUBDIR;
use super::SESSIONS_SUBDIR;
use crate::protocol::EventMsg;
use codex_file_search as file_search;
@@ -1056,9 +1054,11 @@ fn truncate_to_seconds(dt: OffsetDateTime) -> Option<OffsetDateTime> {
dt.replace_nanosecond(0).ok()
}
async fn find_thread_path_by_id_str_in_subdir(
/// Locate a recorded thread rollout file by its UUID string using the existing
/// paginated listing implementation. Returns `Ok(Some(path))` if found, `Ok(None)` if not present
/// or the id is invalid.
pub async fn find_thread_path_by_id_str(
codex_home: &Path,
subdir: &str,
id_str: &str,
) -> io::Result<Option<PathBuf>> {
// Validate UUID format early.
@@ -1067,7 +1067,7 @@ async fn find_thread_path_by_id_str_in_subdir(
}
let mut root = codex_home.to_path_buf();
root.push(subdir);
root.push(SESSIONS_SUBDIR);
if !root.exists() {
return Ok(None);
}
@@ -1099,31 +1099,3 @@ async fn find_thread_path_by_id_str_in_subdir(
.next()
.map(|m| root.join(m.path)))
}
/// Locate a recorded thread rollout file by its UUID string using the existing
/// paginated listing implementation. Returns `Ok(Some(path))` if found, `Ok(None)` if not present
/// or the id is invalid.
pub async fn find_thread_path_by_id_str(
codex_home: &Path,
id_str: &str,
) -> io::Result<Option<PathBuf>> {
find_thread_path_by_id_str_in_subdir(codex_home, SESSIONS_SUBDIR, id_str).await
}
/// Locate an archived thread rollout file by its UUID string.
pub async fn find_archived_thread_path_by_id_str(
codex_home: &Path,
id_str: &str,
) -> io::Result<Option<PathBuf>> {
find_thread_path_by_id_str_in_subdir(codex_home, ARCHIVED_SESSIONS_SUBDIR, id_str).await
}
/// Extract the `YYYY/MM/DD` directory components from a rollout filename.
pub fn rollout_date_parts(file_name: &OsStr) -> Option<(String, String, String)> {
let name = file_name.to_string_lossy();
let date = name.strip_prefix("rollout-")?.get(..10)?;
let year = date.get(..4)?.to_string();
let month = date.get(5..7)?.to_string();
let day = date.get(8..10)?.to_string();
Some((year, month, day))
}

View File

@@ -15,11 +15,9 @@ pub(crate) mod truncation;
pub use codex_protocol::protocol::SessionMeta;
pub(crate) use error::map_session_init_error;
pub use list::find_archived_thread_path_by_id_str;
pub use list::find_thread_path_by_id_str;
#[deprecated(note = "use find_thread_path_by_id_str")]
pub use list::find_thread_path_by_id_str as find_conversation_path_by_id_str;
pub use list::rollout_date_parts;
pub use recorder::RolloutRecorder;
pub use recorder::RolloutRecorderParams;

View File

@@ -1,6 +1,5 @@
#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::ffi::OsStr;
use std::fs::File;
use std::fs::FileTimes;
use std::fs::{self};
@@ -22,7 +21,6 @@ use crate::rollout::list::ThreadItem;
use crate::rollout::list::ThreadSortKey;
use crate::rollout::list::ThreadsPage;
use crate::rollout::list::get_threads;
use crate::rollout::rollout_date_parts;
use anyhow::Result;
use codex_protocol::ThreadId;
use codex_protocol::models::ContentItem;
@@ -45,16 +43,6 @@ fn provider_vec(providers: &[&str]) -> Vec<String> {
.collect()
}
#[test]
fn rollout_date_parts_extracts_directory_components() {
let file_name = OsStr::new("rollout-2025-03-01T09-00-00-123.jsonl");
let parts = rollout_date_parts(file_name);
assert_eq!(
parts,
Some(("2025".to_string(), "03".to_string(), "01".to_string()))
);
}
fn write_session_file(
root: &Path,
ts_str: &str,

View File

@@ -41,7 +41,7 @@ pub(crate) use undo::UndoTask;
pub(crate) use user_shell::UserShellCommandTask;
const GRACEFULL_INTERRUPTION_TIMEOUT_MS: u64 = 100;
const TURN_ABORTED_INTERRUPTED_GUIDANCE: &str = "The user interrupted the previous turn on purpose. If any tools/commands were aborted, they may have partially executed; verify current state before retrying.";
const TURN_ABORTED_INTERRUPTED_GUIDANCE: &str = "The user interrupted the previous turn. Do not continue or repeat work from that turn unless the user explicitly asks. If any tools/commands were aborted, they may have partially executed; verify current state before retrying.";
/// Thin wrapper that exposes the parts of [`Session`] task runners need.
#[derive(Clone)]
@@ -253,7 +253,7 @@ impl Session {
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: format!(
"{TURN_ABORTED_OPEN_TAG}\n{TURN_ABORTED_INTERRUPTED_GUIDANCE}\n</turn_aborted>"
"{TURN_ABORTED_OPEN_TAG}\n <turn_id>{sub_id}</turn_id>\n <reason>interrupted</reason>\n <guidance>{TURN_ABORTED_INTERRUPTED_GUIDANCE}</guidance>\n</turn_aborted>"
),
}],
end_turn: None,

View File

@@ -2,10 +2,6 @@
You work in 2 phases and you should *chat your way* to a great plan before finalizing it.
While in **Plan Mode**, you must not perform any mutating or execution actions. Once you enter Plan Mode, you remain there until you are **explicitly instructed otherwise**. Plan Mode may continue across multiple user messages unless a developer message ends it.
User intent, tone, or imperative language does **not** trigger a mode change. If a user asks for execution while you are still in Plan Mode, you must treat that request as a prompt to **plan the execution**, not to carry it out.
PHASE 1 — Intent chat (what they actually want)
- Keep asking until you can clearly state: goal + success criteria, audience, in/out of scope, constraints, current state, and the key preferences/tradeoffs.
- Bias toward questions over guessing: if any highimpact ambiguity remains, do NOT plan yet—ask.
@@ -28,7 +24,6 @@ You SHOULD ask many questions, but each question must:
- materially change the spec/plan, OR
- confirm/lock an assumption, OR
- choose between meaningful tradeoffs.
- not be answerable by non-mutating commands
Batch questions (e.g., 410) per `request_user_input` call to keep momentum.
## Two kinds of unknowns (treat differently)

View File

@@ -49,7 +49,7 @@ async fn emits_deprecation_notice_for_legacy_feature_flag() -> anyhow::Result<()
assert_eq!(
details.as_deref(),
Some(
"Enable it with `--enable unified_exec` or `[features].unified_exec` in config.toml. See https://developers.openai.com/codex/config-advanced/ for details."
"Enable it with `--enable unified_exec` or `[features].unified_exec` in config.toml. See https://github.com/openai/codex/blob/main/docs/config.md#feature-flags for details."
),
);

View File

@@ -93,7 +93,6 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> {
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
);
config
@@ -234,7 +233,6 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> {
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
);
config
@@ -433,7 +431,6 @@ async fn stdio_image_completions_round_trip() -> anyhow::Result<()> {
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
);
config
@@ -580,7 +577,6 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> {
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
);
config
@@ -738,7 +734,6 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> {
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
);
config
@@ -928,7 +923,6 @@ async fn streamable_http_with_oauth_round_trip() -> anyhow::Result<()> {
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
);
config

View File

@@ -3,15 +3,14 @@ use std::io::Write;
use std::path::Path;
use std::path::PathBuf;
use codex_core::find_archived_thread_path_by_id_str;
use codex_core::find_thread_path_by_id_str;
use tempfile::TempDir;
use uuid::Uuid;
/// Create <subdir>/YYYY/MM/DD and write a minimal rollout file containing the
/// Create sessions/YYYY/MM/DD and write a minimal rollout file containing the
/// provided conversation id in the SessionMeta line. Returns the absolute path.
fn write_minimal_rollout_with_id_in_subdir(codex_home: &Path, subdir: &str, id: Uuid) -> PathBuf {
let sessions = codex_home.join(subdir).join("2024/01/01");
fn write_minimal_rollout_with_id(codex_home: &Path, id: Uuid) -> PathBuf {
let sessions = codex_home.join("sessions/2024/01/01");
std::fs::create_dir_all(&sessions).unwrap();
let file = sessions.join(format!("rollout-2024-01-01T00-00-00-{id}.jsonl"));
@@ -38,12 +37,6 @@ fn write_minimal_rollout_with_id_in_subdir(codex_home: &Path, subdir: &str, id:
file
}
/// Create sessions/YYYY/MM/DD and write a minimal rollout file containing the
/// provided conversation id in the SessionMeta line. Returns the absolute path.
fn write_minimal_rollout_with_id(codex_home: &Path, id: Uuid) -> PathBuf {
write_minimal_rollout_with_id_in_subdir(codex_home, "sessions", id)
}
#[tokio::test]
async fn find_locates_rollout_file_by_id() {
let home = TempDir::new().unwrap();
@@ -86,16 +79,3 @@ async fn find_ignores_granular_gitignore_rules() {
assert_eq!(found, Some(expected));
}
#[tokio::test]
async fn find_archived_locates_rollout_file_by_id() {
let home = TempDir::new().unwrap();
let id = Uuid::new_v4();
let expected = write_minimal_rollout_with_id_in_subdir(home.path(), "archived_sessions", id);
let found = find_archived_thread_path_by_id_str(home.path(), &id.to_string())
.await
.unwrap();
assert_eq!(found, Some(expected));
}

View File

@@ -431,7 +431,6 @@ async fn mcp_tool_call_output_exceeds_limit_truncated_for_model() -> Result<()>
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
);
config
@@ -524,7 +523,6 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> {
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
);
config
@@ -788,7 +786,6 @@ async fn mcp_tool_call_output_not_truncated_with_custom_limit() -> Result<()> {
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
);
config

View File

@@ -173,7 +173,6 @@ impl AppServerClient {
limit: None,
sort_key: None,
model_providers: None,
source_kinds: None,
archived: None,
},
};

View File

@@ -79,10 +79,6 @@ Interrupt a running turn: `interruptConversation`.
List/resume/archive: `listConversations`, `resumeConversation`, `archiveConversation`.
For v2 threads, use `thread/list` with `archived: true` to list archived rollouts and
`thread/unarchive` to restore them to the active sessions directory (it returns the restored
thread summary).
## Models
Fetch the catalog of models available in the current Codex build with `model/list`. The request accepts optional pagination inputs:

View File

@@ -109,7 +109,6 @@ use super::footer::toggle_shortcut_mode;
use super::paste_burst::CharDecision;
use super::paste_burst::PasteBurst;
use super::skill_popup::SkillPopup;
use super::slash_commands;
use crate::bottom_pane::paste_burst::FlushResult;
use crate::bottom_pane::prompt_args::expand_custom_prompt;
use crate::bottom_pane::prompt_args::expand_if_numeric_with_positional_args;
@@ -121,6 +120,7 @@ use crate::render::Insets;
use crate::render::RectExt;
use crate::render::renderable::Renderable;
use crate::slash_command::SlashCommand;
use crate::slash_command::built_in_slash_commands;
use crate::style::user_message_style;
use codex_common::fuzzy_match::fuzzy_match;
use codex_protocol::custom_prompts::CustomPrompt;
@@ -148,6 +148,13 @@ use std::path::PathBuf;
use std::time::Duration;
use std::time::Instant;
fn windows_degraded_sandbox_active() -> bool {
cfg!(target_os = "windows")
&& codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
&& codex_core::get_platform_sandbox().is_some()
&& !codex_core::is_windows_elevated_sandbox_enabled()
}
/// If the pasted content exceeds this number of characters, replace it with a
/// placeholder in the UI.
const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000;
@@ -190,30 +197,6 @@ enum PromptSelectionAction {
},
}
/// Feature flags for reusing the chat composer in other bottom-pane surfaces.
///
/// The default keeps today's behavior intact. Other call sites can opt out of
/// specific behaviors by constructing a config with those flags set to `false`.
#[derive(Clone, Copy, Debug)]
pub(crate) struct ChatComposerConfig {
/// Whether command/file/skill popups are allowed to appear.
pub(crate) popups_enabled: bool,
/// Whether `/...` input is parsed and dispatched as slash commands.
pub(crate) slash_commands_enabled: bool,
/// Whether pasting a file path can attach local images.
pub(crate) image_paste_enabled: bool,
}
impl Default for ChatComposerConfig {
fn default() -> Self {
Self {
popups_enabled: true,
slash_commands_enabled: true,
image_paste_enabled: true,
}
}
}
pub(crate) struct ChatComposer {
textarea: TextArea,
textarea_state: RefCell<TextAreaState>,
@@ -251,7 +234,6 @@ pub(crate) struct ChatComposer {
/// When enabled, `Enter` submits immediately and `Tab` requests queuing behavior.
steer_enabled: bool,
collaboration_modes_enabled: bool,
config: ChatComposerConfig,
collaboration_mode_indicator: Option<CollaborationModeIndicator>,
personality_command_enabled: bool,
}
@@ -279,28 +261,6 @@ impl ChatComposer {
enhanced_keys_supported: bool,
placeholder_text: String,
disable_paste_burst: bool,
) -> Self {
Self::new_with_config(
has_input_focus,
app_event_tx,
enhanced_keys_supported,
placeholder_text,
disable_paste_burst,
ChatComposerConfig::default(),
)
}
/// Construct a composer with explicit feature gating.
///
/// This enables reuse in contexts like request-user-input where we want
/// the same visuals and editing behavior without slash commands or popups.
pub(crate) fn new_with_config(
has_input_focus: bool,
app_event_tx: AppEventSender,
enhanced_keys_supported: bool,
placeholder_text: String,
disable_paste_burst: bool,
config: ChatComposerConfig,
) -> Self {
let use_shift_enter_hint = enhanced_keys_supported;
@@ -336,7 +296,6 @@ impl ChatComposer {
dismissed_skill_popup_token: None,
steer_enabled: false,
collaboration_modes_enabled: false,
config,
collaboration_mode_indicator: None,
personality_command_enabled: false,
};
@@ -374,18 +333,6 @@ impl ChatComposer {
self.personality_command_enabled = enabled;
}
/// Centralized feature gating keeps config checks out of call sites.
fn popups_enabled(&self) -> bool {
self.config.popups_enabled
}
fn slash_commands_enabled(&self) -> bool {
self.config.slash_commands_enabled
}
fn image_paste_enabled(&self) -> bool {
self.config.image_paste_enabled
}
fn layout_areas(&self, area: Rect) -> [Rect; 3] {
let footer_props = self.footer_props();
let footer_hint_height = self
@@ -470,10 +417,7 @@ impl ChatComposer {
let placeholder = self.next_large_paste_placeholder(char_count);
self.textarea.insert_element(&placeholder);
self.pending_pastes.push((placeholder, pasted));
} else if char_count > 1
&& self.image_paste_enabled()
&& self.handle_paste_image_path(pasted.clone())
{
} else if char_count > 1 && self.handle_paste_image_path(pasted.clone()) {
self.textarea.insert_str(" ");
} else {
self.textarea.insert_str(&pasted);
@@ -1690,17 +1634,14 @@ impl ChatComposer {
text = text.trim().to_string();
text_elements = Self::trim_text_elements(&expanded_input, &text, text_elements);
if self.slash_commands_enabled()
&& let Some((name, _rest, _rest_offset)) = parse_slash_name(&text)
{
if let Some((name, _rest, _rest_offset)) = parse_slash_name(&text) {
let treat_as_plain_text = input_starts_with_space || name.contains('/');
if !treat_as_plain_text {
let is_builtin = slash_commands::find_builtin_command(
name,
let is_builtin = Self::built_in_slash_commands_for_input(
self.collaboration_modes_enabled,
self.personality_command_enabled,
)
.is_some();
.any(|(command_name, _)| command_name == name);
let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:");
let is_known_prompt = name
.strip_prefix(&prompt_prefix)
@@ -1729,28 +1670,26 @@ impl ChatComposer {
}
}
if self.slash_commands_enabled() {
let expanded_prompt =
match expand_custom_prompt(&text, &text_elements, &self.custom_prompts) {
Ok(expanded) => expanded,
Err(err) => {
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_error_event(err.user_message()),
)));
self.set_text_content(
original_input.clone(),
original_text_elements,
original_local_image_paths,
);
self.pending_pastes.clone_from(&original_pending_pastes);
self.textarea.set_cursor(original_input.len());
return None;
}
};
if let Some(expanded) = expanded_prompt {
text = expanded.text;
text_elements = expanded.text_elements;
}
let expanded_prompt =
match expand_custom_prompt(&text, &text_elements, &self.custom_prompts) {
Ok(expanded) => expanded,
Err(err) => {
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_error_event(err.user_message()),
)));
self.set_text_content(
original_input.clone(),
original_text_elements,
original_local_image_paths,
);
self.pending_pastes.clone_from(&original_pending_pastes);
self.textarea.set_cursor(original_input.len());
return None;
}
};
if let Some(expanded) = expanded_prompt {
text = expanded.text;
text_elements = expanded.text_elements;
}
// Custom prompt expansion can remove or rewrite image placeholders, so prune any
// attachments that no longer have a corresponding placeholder in the expanded text.
@@ -1790,15 +1729,14 @@ impl ChatComposer {
// If we're in a paste-like burst capture, treat Enter/Ctrl+Shift+Q as part of the burst
// and accumulate it rather than submitting or inserting immediately.
// Do not treat as paste inside a slash-command context.
let in_slash_context = self.slash_commands_enabled()
&& (matches!(self.active_popup, ActivePopup::Command(_))
|| self
.textarea
.text()
.lines()
.next()
.unwrap_or("")
.starts_with('/'));
let in_slash_context = matches!(self.active_popup, ActivePopup::Command(_))
|| self
.textarea
.text()
.lines()
.next()
.unwrap_or("")
.starts_with('/');
if !self.disable_paste_burst
&& self.paste_burst.is_active()
&& !in_slash_context
@@ -1865,17 +1803,14 @@ impl ChatComposer {
/// Check if the first line is a bare slash command (no args) and dispatch it.
/// Returns Some(InputResult) if a command was dispatched, None otherwise.
fn try_dispatch_bare_slash_command(&mut self) -> Option<InputResult> {
if !self.slash_commands_enabled() {
return None;
}
let first_line = self.textarea.text().lines().next().unwrap_or("");
if let Some((name, rest, _rest_offset)) = parse_slash_name(first_line)
&& rest.is_empty()
&& let Some(cmd) = slash_commands::find_builtin_command(
name,
&& let Some((_n, cmd)) = Self::built_in_slash_commands_for_input(
self.collaboration_modes_enabled,
self.personality_command_enabled,
)
.find(|(n, _)| *n == name)
{
self.textarea.set_text_clearing_elements("");
Some(InputResult::Command(cmd))
@@ -1887,9 +1822,6 @@ impl ChatComposer {
/// Check if the input is a slash command with args (e.g., /review args) and dispatch it.
/// Returns Some(InputResult) if a command was dispatched, None otherwise.
fn try_dispatch_slash_command_with_args(&mut self) -> Option<InputResult> {
if !self.slash_commands_enabled() {
return None;
}
let original_input = self.textarea.text().to_string();
let input_starts_with_space = original_input.starts_with(' ');
@@ -1898,11 +1830,11 @@ impl ChatComposer {
if let Some((name, rest, _rest_offset)) = parse_slash_name(&text)
&& !rest.is_empty()
&& !name.contains('/')
&& let Some(cmd) = slash_commands::find_builtin_command(
name,
&& let Some((_n, cmd)) = Self::built_in_slash_commands_for_input(
self.collaboration_modes_enabled,
self.personality_command_enabled,
)
.find(|(command_name, _)| *command_name == name)
&& cmd == SlashCommand::Review
{
self.textarea.set_text_clearing_elements("");
@@ -2256,10 +2188,6 @@ impl ChatComposer {
}
fn sync_popups(&mut self) {
if !self.popups_enabled() {
self.active_popup = ActivePopup::None;
return;
}
let file_token = Self::current_at_token(&self.textarea);
let browsing_history = self
.history
@@ -2272,8 +2200,7 @@ impl ChatComposer {
}
let skill_token = self.current_skill_token();
let allow_command_popup =
self.slash_commands_enabled() && file_token.is_none() && skill_token.is_none();
let allow_command_popup = file_token.is_none() && skill_token.is_none();
self.sync_command_popup(allow_command_popup);
if matches!(self.active_popup, ActivePopup::Command(_)) {
@@ -2334,24 +2261,24 @@ impl ChatComposer {
/// prefix for any known command (built-in or custom prompt).
/// Empty names only count when there is no extra content after the '/'.
fn looks_like_slash_prefix(&self, name: &str, rest_after_name: &str) -> bool {
if !self.slash_commands_enabled() {
return false;
}
if name.is_empty() {
return rest_after_name.is_empty();
}
if slash_commands::has_builtin_prefix(
name,
let builtin_match = Self::built_in_slash_commands_for_input(
self.collaboration_modes_enabled,
self.personality_command_enabled,
) {
)
.any(|(cmd_name, _)| fuzzy_match(cmd_name, name).is_some());
if builtin_match {
return true;
}
self.custom_prompts.iter().any(|prompt| {
fuzzy_match(&format!("{PROMPTS_CMD_PREFIX}:{}", prompt.name), name).is_some()
})
let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:");
self.custom_prompts
.iter()
.any(|p| fuzzy_match(&format!("{prompt_prefix}{}", p.name), name).is_some())
}
/// Synchronize `self.command_popup` with the current text in the
@@ -2409,6 +2336,21 @@ impl ChatComposer {
}
}
}
fn built_in_slash_commands_for_input(
collaboration_modes_enabled: bool,
personality_command_enabled: bool,
) -> impl Iterator<Item = (&'static str, SlashCommand)> {
let allow_elevate_sandbox = windows_degraded_sandbox_active();
built_in_slash_commands()
.into_iter()
.filter(move |(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox)
.filter(move |(_, cmd)| collaboration_modes_enabled || *cmd != SlashCommand::Collab)
.filter(move |(_, cmd)| {
personality_command_enabled || *cmd != SlashCommand::Personality
})
}
pub(crate) fn set_custom_prompts(&mut self, prompts: Vec<CustomPrompt>) {
self.custom_prompts = prompts.clone();
if let ActivePopup::Command(popup) = &mut self.active_popup {

View File

@@ -6,14 +6,21 @@ use super::popup_consts::MAX_POPUP_ROWS;
use super::scroll_state::ScrollState;
use super::selection_popup_common::GenericDisplayRow;
use super::selection_popup_common::render_rows;
use super::slash_commands;
use crate::render::Insets;
use crate::render::RectExt;
use crate::slash_command::SlashCommand;
use crate::slash_command::built_in_slash_commands;
use codex_protocol::custom_prompts::CustomPrompt;
use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX;
use std::collections::HashSet;
fn windows_degraded_sandbox_active() -> bool {
cfg!(target_os = "windows")
&& codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
&& codex_core::get_platform_sandbox().is_some()
&& !codex_core::is_windows_elevated_sandbox_enabled()
}
/// A selectable item in the popup: either a built-in command or a user prompt.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum CommandItem {
@@ -37,11 +44,15 @@ pub(crate) struct CommandPopupFlags {
impl CommandPopup {
pub(crate) fn new(mut prompts: Vec<CustomPrompt>, flags: CommandPopupFlags) -> Self {
// Keep built-in availability in sync with the composer.
let builtins = slash_commands::builtins_for_input(
flags.collaboration_modes_enabled,
flags.personality_command_enabled,
);
let allow_elevate_sandbox = windows_degraded_sandbox_active();
let builtins: Vec<(&'static str, SlashCommand)> = built_in_slash_commands()
.into_iter()
.filter(|(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox)
.filter(|(_, cmd)| flags.collaboration_modes_enabled || *cmd != SlashCommand::Collab)
.filter(|(_, cmd)| {
flags.personality_command_enabled || *cmd != SlashCommand::Personality
})
.collect();
// Exclude prompts that collide with builtin command names and sort by name.
let exclude: HashSet<String> = builtins.iter().map(|(n, _)| (*n).to_string()).collect();
prompts.retain(|p| !exclude.contains(&p.name));

View File

@@ -29,7 +29,7 @@ use super::selection_popup_common::GenericDisplayRow;
use super::selection_popup_common::measure_rows_height;
use super::selection_popup_common::render_rows;
pub(crate) struct ExperimentalFeatureItem {
pub(crate) struct BetaFeatureItem {
pub feature: Feature,
pub name: String,
pub description: String,
@@ -37,7 +37,7 @@ pub(crate) struct ExperimentalFeatureItem {
}
pub(crate) struct ExperimentalFeaturesView {
features: Vec<ExperimentalFeatureItem>,
features: Vec<BetaFeatureItem>,
state: ScrollState,
complete: bool,
app_event_tx: AppEventSender,
@@ -46,14 +46,11 @@ pub(crate) struct ExperimentalFeaturesView {
}
impl ExperimentalFeaturesView {
pub(crate) fn new(
features: Vec<ExperimentalFeatureItem>,
app_event_tx: AppEventSender,
) -> Self {
pub(crate) fn new(features: Vec<BetaFeatureItem>, app_event_tx: AppEventSender) -> Self {
let mut header = ColumnRenderable::new();
header.push(Line::from("Experimental features".bold()));
header.push(Line::from(
"Toggle experimental features. Changes are saved to config.toml.".dim(),
"Toggle beta features. Changes are saved to config.toml.".dim(),
));
let mut view = Self {

View File

@@ -9,15 +9,18 @@ use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Block;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use super::selection_popup_common::render_menu_surface;
use super::selection_popup_common::wrap_styled_line;
use crate::app_event_sender::AppEventSender;
use crate::key_hint::KeyBinding;
use crate::render::Insets;
use crate::render::RectExt as _;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use crate::style::user_message_style;
use super::CancellationEvent;
use super::bottom_pane_view::BottomPaneView;
@@ -473,16 +476,16 @@ impl Renderable for ListSelectionView {
let [content_area, footer_area] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(footer_rows)]).areas(area);
let outer_content_area = content_area;
// Paint the shared menu surface and then layout inside the returned inset.
let content_area = render_menu_surface(outer_content_area, buf);
Block::default()
.style(user_message_style())
.render(content_area, buf);
let header_height = self
.header
// Subtract 4 for the padding on the left and right of the header.
.desired_height(outer_content_area.width.saturating_sub(4));
.desired_height(content_area.width.saturating_sub(4));
let rows = self.build_rows();
let rows_width = Self::rows_width(outer_content_area.width);
let rows_width = Self::rows_width(content_area.width);
let rows_height = measure_rows_height(
&rows,
&self.state,
@@ -495,7 +498,7 @@ impl Renderable for ListSelectionView {
Constraint::Length(if self.is_searchable { 1 } else { 0 }),
Constraint::Length(rows_height),
])
.areas(content_area);
.areas(content_area.inset(Insets::vh(1, 2)));
if header_area.height < header_height {
let [header_area, elision_area] =

View File

@@ -59,7 +59,6 @@ mod list_selection_view;
mod prompt_args;
mod skill_popup;
mod skills_toggle_view;
mod slash_commands;
pub(crate) use footer::CollaborationModeIndicator;
pub(crate) use list_selection_view::SelectionViewParams;
mod feedback_view;
@@ -109,7 +108,7 @@ pub(crate) use chat_composer::InputResult;
use codex_protocol::custom_prompts::CustomPrompt;
use crate::status_indicator_widget::StatusIndicatorWidget;
pub(crate) use experimental_features_view::ExperimentalFeatureItem;
pub(crate) use experimental_features_view::BetaFeatureItem;
pub(crate) use experimental_features_view::ExperimentalFeaturesView;
pub(crate) use list_selection_view::SelectionAction;
pub(crate) use list_selection_view::SelectionItem;

View File

@@ -7,15 +7,11 @@ use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Block;
use ratatui::widgets::Widget;
use unicode_width::UnicodeWidthChar;
use unicode_width::UnicodeWidthStr;
use crate::key_hint::KeyBinding;
use crate::render::Insets;
use crate::render::RectExt as _;
use crate::style::user_message_style;
use super::scroll_state::ScrollState;
@@ -31,31 +27,6 @@ pub(crate) struct GenericDisplayRow {
pub wrap_indent: Option<usize>, // optional indent for wrapped lines
}
const MENU_SURFACE_INSET_V: u16 = 1;
const MENU_SURFACE_INSET_H: u16 = 2;
/// Apply the shared "menu surface" padding used by bottom-pane overlays.
///
/// Rendering code should generally call [`render_menu_surface`] and then lay
/// out content inside the returned inset rect.
pub(crate) fn menu_surface_inset(area: Rect) -> Rect {
area.inset(Insets::vh(MENU_SURFACE_INSET_V, MENU_SURFACE_INSET_H))
}
/// Paint the shared menu background and return the inset content area.
///
/// This keeps the surface treatment consistent across selection-style overlays
/// (for example `/model`, approvals, and request-user-input).
pub(crate) fn render_menu_surface(area: Rect, buf: &mut Buffer) -> Rect {
if area.is_empty() {
return area;
}
Block::default()
.style(user_message_style())
.render(area, buf);
menu_surface_inset(area)
}
pub(crate) fn wrap_styled_line<'a>(line: &'a Line<'a>, width: u16) -> Vec<Line<'a>> {
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_line;

View File

@@ -1,54 +0,0 @@
//! Shared helpers for filtering and matching built-in slash commands.
//!
//! The same sandbox- and feature-gating rules are used by both the composer
//! and the command popup. Centralizing them here keeps those call sites small
//! and ensures they stay in sync.
use codex_common::fuzzy_match::fuzzy_match;
use crate::slash_command::SlashCommand;
use crate::slash_command::built_in_slash_commands;
/// Whether the Windows degraded-sandbox elevation flow is currently allowed.
pub(crate) fn windows_degraded_sandbox_active() -> bool {
cfg!(target_os = "windows")
&& codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
&& codex_core::get_platform_sandbox().is_some()
&& !codex_core::is_windows_elevated_sandbox_enabled()
}
/// Return the built-ins that should be visible/usable for the current input.
pub(crate) fn builtins_for_input(
collaboration_modes_enabled: bool,
personality_command_enabled: bool,
) -> Vec<(&'static str, SlashCommand)> {
let allow_elevate_sandbox = windows_degraded_sandbox_active();
built_in_slash_commands()
.into_iter()
.filter(|(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox)
.filter(|(_, cmd)| collaboration_modes_enabled || *cmd != SlashCommand::Collab)
.filter(|(_, cmd)| personality_command_enabled || *cmd != SlashCommand::Personality)
.collect()
}
/// Find a single built-in command by exact name, after applying the gating rules.
pub(crate) fn find_builtin_command(
name: &str,
collaboration_modes_enabled: bool,
personality_command_enabled: bool,
) -> Option<SlashCommand> {
builtins_for_input(collaboration_modes_enabled, personality_command_enabled)
.into_iter()
.find(|(command_name, _)| *command_name == name)
.map(|(_, cmd)| cmd)
}
/// Whether any visible built-in fuzzily matches the provided prefix.
pub(crate) fn has_builtin_prefix(
name: &str,
collaboration_modes_enabled: bool,
personality_command_enabled: bool,
) -> bool {
builtins_for_input(collaboration_modes_enabled, personality_command_enabled)
.into_iter()
.any(|(command_name, _)| fuzzy_match(command_name, name).is_some())
}

View File

@@ -133,12 +133,12 @@ use crate::app_event::WindowsSandboxEnableMode;
use crate::app_event::WindowsSandboxFallbackReason;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::ApprovalRequest;
use crate::bottom_pane::BetaFeatureItem;
use crate::bottom_pane::BottomPane;
use crate::bottom_pane::BottomPaneParams;
use crate::bottom_pane::CancellationEvent;
use crate::bottom_pane::CollaborationModeIndicator;
use crate::bottom_pane::DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED;
use crate::bottom_pane::ExperimentalFeatureItem;
use crate::bottom_pane::ExperimentalFeaturesView;
use crate::bottom_pane::InputResult;
use crate::bottom_pane::LocalImageAttachment;
@@ -4029,12 +4029,12 @@ impl ChatWidget {
}
pub(crate) fn open_experimental_popup(&mut self) {
let features: Vec<ExperimentalFeatureItem> = FEATURES
let features: Vec<BetaFeatureItem> = FEATURES
.iter()
.filter_map(|spec| {
let name = spec.stage.experimental_menu_name()?;
let description = spec.stage.experimental_menu_description()?;
Some(ExperimentalFeatureItem {
let name = spec.stage.beta_menu_name()?;
let description = spec.stage.beta_menu_description()?;
Some(BetaFeatureItem {
feature: spec.id,
name: name.to_string(),
description: description.to_string(),

View File

@@ -3,7 +3,7 @@ source: tui/src/chatwidget/tests.rs
expression: popup
---
Experimental features
Toggle experimental features. Changes are saved to config.toml.
Toggle beta features. Changes are saved to config.toml.
[ ] Ghost snapshots Capture undo snapshots each turn.
[x] Shell tool Allow the model to run shell commands.

View File

@@ -2911,13 +2911,13 @@ async fn experimental_features_popup_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
let features = vec![
ExperimentalFeatureItem {
BetaFeatureItem {
feature: Feature::GhostCommit,
name: "Ghost snapshots".to_string(),
description: "Capture undo snapshots each turn.".to_string(),
enabled: false,
},
ExperimentalFeatureItem {
BetaFeatureItem {
feature: Feature::ShellTool,
name: "Shell tool".to_string(),
description: "Allow the model to run shell commands.".to_string(),
@@ -2937,7 +2937,7 @@ async fn experimental_features_toggle_saves_on_exit() {
let expected_feature = Feature::GhostCommit;
let view = ExperimentalFeaturesView::new(
vec![ExperimentalFeatureItem {
vec![BetaFeatureItem {
feature: expected_feature,
name: "Ghost snapshots".to_string(),
description: "Capture undo snapshots each turn.".to_string(),

View File

@@ -1953,7 +1953,6 @@ mod tests {
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
};
let mut servers = config.mcp_servers.get().clone();
servers.insert("docs".to_string(), stdio_config);
@@ -1975,7 +1974,6 @@ mod tests {
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
};
servers.insert("http".to_string(), http_config);
config

View File

@@ -67,7 +67,7 @@ impl SlashCommand {
SlashCommand::Approvals => "choose what Codex can do without approval",
SlashCommand::Permissions => "choose what Codex is allowed to do",
SlashCommand::ElevateSandbox => "set up elevated agent sandbox",
SlashCommand::Experimental => "toggle experimental features",
SlashCommand::Experimental => "toggle beta features",
SlashCommand::Mcp => "list configured MCP tools",
SlashCommand::Logout => "log out of Codex",
SlashCommand::Rollout => "print the rollout file path",

View File

@@ -15,15 +15,15 @@ lazy_static! {
static ref ALL_TOOLTIPS: Vec<&'static str> = {
let mut tips = Vec::new();
tips.extend(TOOLTIPS.iter().copied());
tips.extend(experimental_tooltips());
tips.extend(beta_tooltips());
tips
};
}
fn experimental_tooltips() -> Vec<&'static str> {
fn beta_tooltips() -> Vec<&'static str> {
FEATURES
.iter()
.filter_map(|spec| spec.stage.experimental_announcement())
.filter_map(|spec| spec.stage.beta_announcement())
.collect()
}

View File

@@ -25,7 +25,6 @@ chrono = { version = "0.4.42", default-features = false, features = [
"std",
] }
codex-utils-absolute-path = { workspace = true }
codex-utils-string = { workspace = true }
dunce = "1.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

View File

@@ -4,8 +4,6 @@ use std::path::Path;
use std::path::PathBuf;
use std::sync::OnceLock;
use codex_utils_string::take_bytes_at_char_boundary;
const LOG_COMMAND_PREVIEW_LIMIT: usize = 200;
pub const LOG_FILE_NAME: &str = "sandbox.log";
@@ -24,7 +22,7 @@ fn preview(command: &[String]) -> String {
if joined.len() <= LOG_COMMAND_PREVIEW_LIMIT {
joined
} else {
take_bytes_at_char_boundary(&joined, LOG_COMMAND_PREVIEW_LIMIT).to_string()
joined[..LOG_COMMAND_PREVIEW_LIMIT].to_string()
}
}
@@ -74,19 +72,3 @@ pub fn log_note(msg: &str, base_dir: Option<&Path>) {
let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S%.3f");
append_line(&format!("[{ts} {}] {}", exe_label(), msg), base_dir);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn preview_does_not_panic_on_utf8_boundary() {
// Place a 4-byte emoji such that naive (byte-based) truncation would split it.
let prefix = "x".repeat(LOG_COMMAND_PREVIEW_LIMIT - 1);
let command = vec![format!("{prefix}😀")];
let result = std::panic::catch_unwind(|| preview(&command));
assert!(result.is_ok());
let previewed = result.unwrap();
assert!(previewed.len() <= LOG_COMMAND_PREVIEW_LIMIT);
}
}

View File

@@ -49,31 +49,6 @@ The solution is to detect paste-like _bursts_ and buffer them into a single expl
- After handling the key, `sync_popups()` runs so popup visibility/filters stay consistent with the
latest text + cursor.
## Config gating for reuse
`ChatComposer` now supports feature gating via `ChatComposerConfig`
(`codex-rs/tui/src/bottom_pane/chat_composer.rs`). The default config preserves current chat
behavior.
Flags:
- `popups_enabled`
- `slash_commands_enabled`
- `image_paste_enabled`
Key effects when disabled:
- When `popups_enabled` is `false`, `sync_popups()` forces `ActivePopup::None`.
- When `slash_commands_enabled` is `false`, the composer does not treat `/...` input as commands.
- When `slash_commands_enabled` is `false`, the composer does not expand custom prompts in
`prepare_submission_text`.
- When `slash_commands_enabled` is `false`, slash-context paste-burst exceptions are disabled.
- When `image_paste_enabled` is `false`, file-path paste image attachment is skipped.
Built-in slash command availability is centralized in
`codex-rs/tui/src/bottom_pane/slash_commands.rs` and reused by both the composer and the command
popup so gating stays in sync.
## Submission flow (Enter/Tab)
There are multiple submission paths, but they share the same core rules: