mirror of
https://github.com/openai/codex.git
synced 2026-02-02 15:03:38 +00:00
Compare commits
2 Commits
remove/doc
...
dev/mzeng/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45a414b16a | ||
|
|
1f0621d129 |
4
.github/workflows/rust-release.yml
vendored
4
.github/workflows/rust-release.yml
vendored
@@ -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
1
codex-rs/Cargo.lock
generated
@@ -2006,7 +2006,6 @@ dependencies = [
|
||||
"chrono",
|
||||
"codex-protocol",
|
||||
"codex-utils-absolute-path",
|
||||
"codex-utils-string",
|
||||
"dirs-next",
|
||||
"dunce",
|
||||
"pretty_assertions",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/")]
|
||||
|
||||
@@ -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 thread’s 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 agent’s 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:
|
||||
|
||||
@@ -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(¶ms.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,
|
||||
})
|
||||
|
||||
@@ -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]
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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"
|
||||
"#
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -750,13 +750,6 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"scopes": {
|
||||
"default": null,
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"startup_timeout_ms": {
|
||||
"default": null,
|
||||
"format": "uint64",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 high‑impact 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., 4–10) per `request_user_input` call to keep momentum.
|
||||
|
||||
## Two kinds of unknowns (treat differently)
|
||||
|
||||
@@ -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."
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -173,7 +173,6 @@ impl AppServerClient {
|
||||
limit: None,
|
||||
sort_key: None,
|
||||
model_providers: None,
|
||||
source_kinds: None,
|
||||
archived: None,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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] =
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user