From b985768dc11446c60e092af510b75e5e8863e514 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 27 Apr 2026 23:33:59 -0700 Subject: [PATCH 01/53] Add `codex update` command (#19933) ## Why Addresses #9274 Running `codex update` currently starts an interactive Codex session with `update` as the prompt. That is a rough edge for users who expect a direct self-update command after seeing the existing update notice, and it forces them to copy the suggested package-manager command manually. ## What changed - Added a top-level `codex update` subcommand. - Reused the existing install-channel detection and update command runner that the TUI already uses for update prompts. - Exposed the update-action lookup from `codex-tui` so the CLI can invoke the same behavior. - Added CLI coverage to ensure `codex update` is parsed as a subcommand instead of becoming an interactive prompt. ## Verification - `cargo test -p codex-cli` - `cargo test -p codex-tui update_action::tests` --- codex-rs/cli/src/main.rs | 36 +++++++++++++++++++++++ codex-rs/cli/tests/marketplace_upgrade.rs | 2 +- codex-rs/cli/tests/update.rs | 24 +++++++++++++++ codex-rs/tui/src/lib.rs | 2 ++ codex-rs/tui/src/update_action.rs | 2 +- 5 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 codex-rs/cli/tests/update.rs diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 852dff616d..c134bc8a29 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -134,6 +134,9 @@ enum Subcommand { /// Generate shell completion scripts. Completion(CompletionCommand), + /// Update Codex to the latest version. + Update, + /// Run commands within a Codex-provided sandbox. Sandbox(SandboxArgs), @@ -615,6 +618,25 @@ fn run_update_action(action: UpdateAction) -> anyhow::Result<()> { Ok(()) } +fn run_update_command() -> anyhow::Result<()> { + #[cfg(debug_assertions)] + { + anyhow::bail!( + "`codex update` is not available in debug builds. Install a release build of Codex to use this command." + ); + } + + #[cfg(not(debug_assertions))] + { + let Some(action) = codex_tui::get_update_action() else { + anyhow::bail!( + "Could not detect the Codex installation method. Please update manually: https://developers.openai.com/codex/cli/" + ); + }; + run_update_action(action) + } +} + fn run_execpolicycheck(cmd: ExecPolicyCheckCommand) -> anyhow::Result<()> { cmd.run() } @@ -998,6 +1020,14 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { )?; print_completion(completion_cli); } + Some(Subcommand::Update) => { + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "update", + )?; + run_update_command()?; + } Some(Subcommand::Cloud(mut cloud_cli)) => { reject_remote_mode_for_subcommand( root_remote.as_deref(), @@ -1890,6 +1920,12 @@ mod tests { assert!(matches!(cli.subcommand, Some(Subcommand::Plugin(_)))); } + #[test] + fn update_parses_as_update_subcommand() { + let cli = MultitoolCli::try_parse_from(["codex", "update"]).expect("parse"); + assert!(matches!(cli.subcommand, Some(Subcommand::Update))); + } + #[test] fn plugin_marketplace_remove_parses_under_plugin() { let cli = diff --git a/codex-rs/cli/tests/marketplace_upgrade.rs b/codex-rs/cli/tests/marketplace_upgrade.rs index 081203ebef..268d75358e 100644 --- a/codex-rs/cli/tests/marketplace_upgrade.rs +++ b/codex-rs/cli/tests/marketplace_upgrade.rs @@ -30,7 +30,7 @@ async fn marketplace_upgrade_no_longer_runs_at_top_level() -> Result<()> { .args(["marketplace", "upgrade"]) .assert() .failure() - .stderr(contains("unexpected argument 'upgrade' found")); + .stderr(contains("unrecognized subcommand 'upgrade'")); Ok(()) } diff --git a/codex-rs/cli/tests/update.rs b/codex-rs/cli/tests/update.rs new file mode 100644 index 0000000000..cf1742cda7 --- /dev/null +++ b/codex-rs/cli/tests/update.rs @@ -0,0 +1,24 @@ +use anyhow::Result; +use predicates::str::contains; +use std::path::Path; +use tempfile::TempDir; + +fn codex_command(codex_home: &Path) -> Result { + let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?); + cmd.env("CODEX_HOME", codex_home); + Ok(cmd) +} + +#[cfg(debug_assertions)] +#[tokio::test] +async fn update_does_not_start_interactive_prompt() -> Result<()> { + let codex_home = TempDir::new()?; + + codex_command(codex_home.path())? + .arg("update") + .assert() + .failure() + .stderr(contains("`codex update` is not available in debug builds")); + + Ok(()) +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 6dd527c713..654e90a63d 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -171,6 +171,8 @@ mod tui; mod ui_consts; pub(crate) mod update_action; pub use update_action::UpdateAction; +#[cfg(not(debug_assertions))] +pub use update_action::get_update_action; mod update_prompt; #[cfg(any(not(debug_assertions), test))] mod update_versions; diff --git a/codex-rs/tui/src/update_action.rs b/codex-rs/tui/src/update_action.rs index 1ac2ff675f..aca065440c 100644 --- a/codex-rs/tui/src/update_action.rs +++ b/codex-rs/tui/src/update_action.rs @@ -59,7 +59,7 @@ impl UpdateAction { } #[cfg(not(debug_assertions))] -pub(crate) fn get_update_action() -> Option { +pub fn get_update_action() -> Option { UpdateAction::from_install_context(InstallContext::current()) } From 7d72fc8f5323175d9d323222a5b98b4bb8a361d5 Mon Sep 17 00:00:00 2001 From: xl-openai Date: Tue, 28 Apr 2026 00:53:27 -0700 Subject: [PATCH 02/53] feat: Cache remote plugin bundles on install (#19914) Remote installs now fetch, validate, download, and cache the plugin bundle locally --- MODULE.bazel.lock | 1 + codex-rs/Cargo.lock | 14 + codex-rs/Cargo.toml | 2 + codex-rs/app-server/Cargo.toml | 2 + .../src/codex_message_processor/plugins.rs | 71 +- .../tests/suite/v2/plugin_install.rs | 481 ++++++++-- .../app-server/tests/suite/v2/plugin_list.rs | 11 +- .../app-server/tests/suite/v2/plugin_read.rs | 15 +- codex-rs/core-plugins/Cargo.toml | 2 + codex-rs/core-plugins/src/lib.rs | 1 + codex-rs/core-plugins/src/remote.rs | 72 +- codex-rs/core-plugins/src/remote_bundle.rs | 848 ++++++++++++++++++ codex-rs/core-plugins/src/store.rs | 2 +- 13 files changed, 1396 insertions(+), 126 deletions(-) create mode 100644 codex-rs/core-plugins/src/remote_bundle.rs diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 7c0b30feba..e079e3af0e 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -1560,6 +1560,7 @@ "system-deps_7.0.7": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"assert_matches\",\"req\":\"^1.5\"},{\"features\":[\"targets\"],\"name\":\"cfg-expr\",\"req\":\">=0.17, <0.21\"},{\"name\":\"heck\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.14\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1\"},{\"name\":\"pkg-config\",\"req\":\"^0.3.25\"},{\"default_features\":false,\"features\":[\"parse\",\"std\"],\"name\":\"toml\",\"req\":\"^0.9\"},{\"name\":\"version-compare\",\"req\":\"^0.2\"}],\"features\":{}}", "tagptr_0.2.0": "{\"dependencies\":[],\"features\":{}}", "tar_0.4.44": "{\"dependencies\":[{\"name\":\"filetime\",\"req\":\"^0.2.8\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"},{\"name\":\"xattr\",\"optional\":true,\"req\":\"^1.1.3\",\"target\":\"cfg(unix)\"}],\"features\":{\"default\":[\"xattr\"]}}", + "tar_0.4.45": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"astral-tokio-tar\",\"req\":\"^0.5\"},{\"name\":\"filetime\",\"req\":\"^0.2.8\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"},{\"features\":[\"macros\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"name\":\"xattr\",\"optional\":true,\"req\":\"^1.1.3\",\"target\":\"cfg(unix)\"}],\"features\":{\"default\":[\"xattr\"]}}", "target-lexicon_0.13.3": "{\"dependencies\":[{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"arch_z80\":[],\"arch_zkasm\":[],\"default\":[],\"serde_support\":[\"serde\",\"std\"],\"std\":[]}}", "tempfile_3.27.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"name\":\"fastrand\",\"req\":\"^2.1.1\"},{\"default_features\":false,\"name\":\"getrandom\",\"optional\":true,\"req\":\">=0.3.0, <0.5\",\"target\":\"cfg(any(unix, windows, target_os = \\\"wasi\\\"))\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"once_cell\",\"req\":\"^1.19.0\"},{\"features\":[\"fs\"],\"name\":\"rustix\",\"req\":\"^1.1.4\",\"target\":\"cfg(any(unix, target_os = \\\"wasi\\\"))\"},{\"features\":[\"Win32_Storage_FileSystem\",\"Win32_Foundation\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"getrandom\"],\"nightly\":[]}}", "temporal_capi_0.1.2": "{\"dependencies\":[{\"default_features\":false,\"name\":\"diplomat\",\"req\":\"^0.14.0\"},{\"default_features\":false,\"name\":\"diplomat-runtime\",\"req\":\"^0.14.0\"},{\"default_features\":false,\"features\":[\"unstable\"],\"name\":\"icu_calendar\",\"req\":\"^2.1.0\"},{\"name\":\"icu_locale\",\"req\":\"^2.1.0\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2.19\"},{\"default_features\":false,\"name\":\"temporal_rs\",\"req\":\"^0.1.2\"},{\"name\":\"timezone_provider\",\"req\":\"^0.1.2\"},{\"name\":\"writeable\",\"req\":\"^0.6.0\"},{\"name\":\"zoneinfo64\",\"optional\":true,\"req\":\"^0.2.0\"}],\"features\":{\"compiled_data\":[\"temporal_rs/compiled_data\"],\"zoneinfo64\":[\"dep:zoneinfo64\",\"timezone_provider/zoneinfo64\"]}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index a00873ee48..de11d1c158 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1880,6 +1880,7 @@ dependencies = [ "codex-utils-rustls-provider", "constant_time_eq 0.3.1", "core_test_support", + "flate2", "futures", "gethostname", "hmac", @@ -1895,6 +1896,7 @@ dependencies = [ "serial_test", "sha2", "shlex", + "tar", "tempfile", "thiserror 2.0.18", "time", @@ -2479,11 +2481,13 @@ dependencies = [ "codex-utils-absolute-path", "codex-utils-plugins", "dirs", + "flate2", "libc", "pretty_assertions", "reqwest", "serde", "serde_json", + "tar", "tempfile", "thiserror 2.0.18", "tokio", @@ -12278,6 +12282,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" +[[package]] +name = "tar" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +dependencies = [ + "filetime", + "libc", +] + [[package]] name = "target-lexicon" version = "0.13.3" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 1bbb138347..d39651fedd 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -260,6 +260,7 @@ encoding_rs = "0.8.35" env-flags = "0.1.1" env_logger = "0.11.9" eventsource-stream = "0.2.3" +flate2 = "1.1.8" futures = { version = "0.3", default-features = false } gethostname = "1.1.0" gix = { version = "0.81.0", default-features = false, features = ["sha1"] } @@ -352,6 +353,7 @@ strum_macros = "0.28.0" supports-color = "3.0.2" syntect = "5" sys-locale = "0.3.2" +tar = { version = "=0.4.45", default-features = false } tempfile = "3.23.0" test-log = "0.2.19" textwrap = "0.16.2" diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 06ed624c37..d78486f38f 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -105,6 +105,7 @@ axum = { workspace = true, default-features = false, features = [ core_test_support = { workspace = true } codex-model-provider-info = { workspace = true } codex-utils-cargo-bin = { workspace = true } +flate2 = { workspace = true } opentelemetry = { workspace = true } opentelemetry_sdk = { workspace = true } pretty_assertions = { workspace = true } @@ -115,6 +116,7 @@ rmcp = { workspace = true, default-features = false, features = [ "transport-streamable-http-server", ] } serial_test = { workspace = true } +tar = { workspace = true } tokio-tungstenite = { workspace = true } tracing-opentelemetry = { workspace = true } wiremock = { workspace = true } diff --git a/codex-rs/app-server/src/codex_message_processor/plugins.rs b/codex-rs/app-server/src/codex_message_processor/plugins.rs index e1d0fffad3..11709d8e3f 100644 --- a/codex-rs/app-server/src/codex_message_processor/plugins.rs +++ b/codex-rs/app-server/src/codex_message_processor/plugins.rs @@ -421,22 +421,44 @@ impl CodexMessageProcessor { let remote_plugin_service_config = RemotePluginServiceConfig { chatgpt_base_url: config.chatgpt_base_url.clone(), }; - let remote_detail = codex_core_plugins::remote::fetch_remote_plugin_detail( - &remote_plugin_service_config, - auth.as_ref(), - &remote_marketplace_name, - &plugin_name, - ) - .await - .map_err(|err| { - remote_plugin_catalog_error_to_jsonrpc(err, "read remote plugin details before install") - })?; + let remote_detail = + codex_core_plugins::remote::fetch_remote_plugin_detail_with_download_urls( + &remote_plugin_service_config, + auth.as_ref(), + &remote_marketplace_name, + &plugin_name, + ) + .await + .map_err(|err| { + remote_plugin_catalog_error_to_jsonrpc( + err, + "read remote plugin details before install", + ) + })?; if remote_detail.summary.install_policy == PluginInstallPolicy::NotAvailable { return Err(invalid_request(format!( "remote plugin {plugin_name} is not available for install" ))); } + let validated_bundle = codex_core_plugins::remote_bundle::validate_remote_plugin_bundle( + &plugin_name, + &remote_marketplace_name, + &remote_detail.summary.name, + remote_detail.release_version.as_deref(), + remote_detail.bundle_download_url.as_deref(), + ) + .map_err(remote_plugin_bundle_install_error_to_jsonrpc)?; + let result = codex_core_plugins::remote_bundle::download_and_install_remote_plugin_bundle( + config.codex_home.to_path_buf(), + validated_bundle, + ) + .await + .map_err(remote_plugin_bundle_install_error_to_jsonrpc)?; + + // Cache first so a backend install cannot succeed when local materialization fails. + // If this backend call fails, the cache entry is harmless because remote installed state + // is still backend-gated. codex_core_plugins::remote::install_remote_plugin( &remote_plugin_service_config, auth.as_ref(), @@ -446,18 +468,29 @@ impl CodexMessageProcessor { .await .map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "install remote plugin"))?; + // TODO(remote plugins): remote marketplaces do not yet have a local + // marketplace/read-path sync, so this install path reads MCP/apps directly + // from the just-cached bundle. self.clear_plugin_related_caches(); - let plugin_apps = remote_detail - .app_ids - .into_iter() - .map(codex_core::plugins::AppConnectorId) - .collect::>(); + let plugin_mcp_servers = load_plugin_mcp_servers(result.installed_path.as_path()).await; + if !plugin_mcp_servers.is_empty() { + if let Err(err) = self.queue_mcp_server_refresh_for_config(&config).await { + warn!( + plugin = result.plugin_id.as_key(), + "failed to queue MCP refresh after remote plugin install: {err:?}" + ); + } + self.start_plugin_mcp_oauth_logins(&config, plugin_mcp_servers) + .await; + } + + let plugin_apps = load_plugin_apps(result.installed_path.as_path()).await; let apps_needing_auth = self .plugin_apps_needing_auth_for_install( &config, auth.as_ref().is_some_and(CodexAuth::is_chatgpt_auth), - &plugin_name, + &result.plugin_id.as_key(), &plugin_apps, ) .await; @@ -708,3 +741,9 @@ fn remote_plugin_catalog_error_to_jsonrpc( }, } } + +fn remote_plugin_bundle_install_error_to_jsonrpc( + err: codex_core_plugins::remote_bundle::RemotePluginBundleInstallError, +) -> JSONRPCErrorError { + internal_error(format!("install remote plugin bundle: {err}")) +} diff --git a/codex-rs/app-server/tests/suite/v2/plugin_install.rs b/codex-rs/app-server/tests/suite/v2/plugin_install.rs index 88403d8919..a115c83077 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -28,6 +28,8 @@ use codex_app_server_protocol::PluginInstallResponse; use codex_app_server_protocol::RequestId; use codex_config::types::AuthCredentialsStoreMode; use codex_utils_absolute_path::AbsolutePathBuf; +use flate2::Compression; +use flate2::write::GzEncoder; use pretty_assertions::assert_eq; use rmcp::handler::server::ServerHandler; use rmcp::model::JsonObject; @@ -45,8 +47,10 @@ use tempfile::TempDir; use tokio::net::TcpListener; use tokio::task::JoinHandle; use tokio::time::timeout; +use wiremock::Match; use wiremock::Mock; use wiremock::MockServer; +use wiremock::Request; use wiremock::ResponseTemplate; use wiremock::matchers::header; use wiremock::matchers::method; @@ -56,6 +60,9 @@ use wiremock::matchers::query_param; // Plugin install tests wait on connector discovery after the install response path // starts, which is noticeably slower on Windows CI. const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60); +const REMOTE_PLUGIN_ID: &str = "plugins~Plugin_00000000000000000000000000000000"; +const TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS: &str = + "CODEX_TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS"; #[tokio::test] async fn plugin_install_rejects_relative_marketplace_paths() -> Result<()> { @@ -154,7 +161,7 @@ async fn plugin_install_rejects_remote_marketplace_when_remote_plugin_is_disable .send_plugin_install_request(PluginInstallParams { marketplace_path: None, remote_marketplace_name: Some("chatgpt-global".to_string()), - plugin_name: "plugins~Plugin_sample".to_string(), + plugin_name: "plugins~Plugin_22222222222222222222222222222222".to_string(), }) .await?; @@ -175,84 +182,36 @@ async fn plugin_install_rejects_remote_marketplace_when_remote_plugin_is_disable } #[tokio::test] -async fn plugin_install_writes_remote_plugin_to_cloud_when_remote_plugin_enabled() -> Result<()> { +async fn plugin_install_writes_remote_plugin_to_cloud_and_cache() -> Result<()> { let codex_home = TempDir::new()?; let server = MockServer::start().await; - write_remote_plugin_catalog_config( + let installed_path = codex_home + .path() + .join("plugins/cache/chatgpt-global/linear/1.2.3"); + let bundle_url = mount_remote_plugin_bundle( + &server, + /*status_code*/ 200, + remote_plugin_bundle_tar_gz_bytes("linear")?, + ) + .await; + configure_remote_plugin_test(codex_home.path(), &server)?; + mount_remote_plugin_detail(&server, REMOTE_PLUGIN_ID, "1.2.3", Some(&bundle_url)).await; + mount_empty_remote_installed_plugins(&server).await; + mount_remote_plugin_install_after_cache_write( + &server, + REMOTE_PLUGIN_ID, + installed_path.join(".codex-plugin/plugin.json"), + ) + .await; + + let mut mcp = McpProcess::new_with_env( codex_home.path(), - &format!("{}/backend-api/", server.uri()), - )?; - write_chatgpt_auth( - codex_home.path(), - ChatGptAuthFixture::new("chatgpt-token") - .account_id("account-123") - .chatgpt_user_id("user-123") - .chatgpt_account_id("account-123"), - AuthCredentialsStoreMode::File, - )?; - - let detail_body = r#"{ - "id": "plugins~Plugin_linear", - "name": "linear", - "scope": "GLOBAL", - "installation_policy": "AVAILABLE", - "authentication_policy": "ON_USE", - "release": { - "display_name": "Linear", - "description": "Track work in Linear", - "app_ids": [], - "interface": { - "short_description": "Plan and track work" - }, - "skills": [] - } -}"#; - let empty_installed_body = r#"{ - "plugins": [], - "pagination": { - "limit": 50, - "next_page_token": null - } -}"#; - - Mock::given(method("GET")) - .and(path("/backend-api/ps/plugins/plugins~Plugin_linear")) - .and(header("authorization", "Bearer chatgpt-token")) - .and(header("chatgpt-account-id", "account-123")) - .respond_with(ResponseTemplate::new(200).set_body_string(detail_body)) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/backend-api/ps/plugins/installed")) - .and(query_param("scope", "GLOBAL")) - .and(header("authorization", "Bearer chatgpt-token")) - .and(header("chatgpt-account-id", "account-123")) - .respond_with(ResponseTemplate::new(200).set_body_string(empty_installed_body)) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path( - "/backend-api/ps/plugins/plugins~Plugin_linear/install", - )) - .and(header("authorization", "Bearer chatgpt-token")) - .and(header("chatgpt-account-id", "account-123")) - .respond_with( - ResponseTemplate::new(200) - .set_body_string(r#"{"id":"plugins~Plugin_linear","enabled":true}"#), - ) - .mount(&server) - .await; - - let mut mcp = McpProcess::new(codex_home.path()).await?; + &[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))], + ) + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; - let request_id = mcp - .send_plugin_install_request(PluginInstallParams { - marketplace_path: None, - remote_marketplace_name: Some("chatgpt-global".to_string()), - plugin_name: "plugins~Plugin_linear".to_string(), - }) - .await?; + let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; let response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(request_id)), @@ -270,10 +229,156 @@ async fn plugin_install_writes_remote_plugin_to_cloud_when_remote_plugin_enabled wait_for_remote_plugin_request_count( &server, "POST", - "/ps/plugins/plugins~Plugin_linear/install", + &format!("/ps/plugins/{REMOTE_PLUGIN_ID}/install"), /*expected_count*/ 1, ) .await?; + wait_for_remote_plugin_request_count( + &server, + "GET", + "/bundles/linear.tar.gz", + /*expected_count*/ 1, + ) + .await?; + assert!(installed_path.join(".codex-plugin/plugin.json").is_file()); + assert!(installed_path.join("skills/plan-work/SKILL.md").is_file()); + assert!( + !codex_home + .path() + .join(format!( + "plugins/cache/chatgpt-global/{REMOTE_PLUGIN_ID}/1.2.3" + )) + .exists() + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_install_rejects_missing_remote_bundle_url() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + configure_remote_plugin_test(codex_home.path(), &server)?; + mount_remote_plugin_detail( + &server, + REMOTE_PLUGIN_ID, + "1.2.3", + /*bundle_download_url*/ None, + ) + .await; + mount_empty_remote_installed_plugins(&server).await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32603); + assert!( + err.error + .message + .contains("backend did not return a download URL") + ); + wait_for_remote_plugin_request_count( + &server, + "POST", + &format!("/ps/plugins/{REMOTE_PLUGIN_ID}/install"), + /*expected_count*/ 0, + ) + .await?; + assert!( + !codex_home + .path() + .join("plugins/cache/chatgpt-global/linear") + .exists() + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_install_rejects_plain_http_remote_bundle_url() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + let bundle_url = format!("{}/bundles/linear.tar.gz", server.uri()); + configure_remote_plugin_test(codex_home.path(), &server)?; + mount_remote_plugin_detail(&server, REMOTE_PLUGIN_ID, "1.2.3", Some(&bundle_url)).await; + mount_empty_remote_installed_plugins(&server).await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32603); + assert!( + err.error + .message + .contains("unsupported download URL scheme") + ); + wait_for_remote_plugin_request_count( + &server, + "POST", + &format!("/ps/plugins/{REMOTE_PLUGIN_ID}/install"), + /*expected_count*/ 0, + ) + .await?; + assert!( + !codex_home + .path() + .join("plugins/cache/chatgpt-global/linear") + .exists() + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_install_rejects_invalid_remote_release_version() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + configure_remote_plugin_test(codex_home.path(), &server)?; + mount_remote_plugin_detail( + &server, + REMOTE_PLUGIN_ID, + "../1.2.3", + Some("https://127.0.0.1:1/bundles/linear.tar.gz"), + ) + .await; + mount_empty_remote_installed_plugins(&server).await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32603); + assert!(err.error.message.contains("invalid release version")); + wait_for_remote_plugin_request_count( + &server, + "POST", + &format!("/ps/plugins/{REMOTE_PLUGIN_ID}/install"), + /*expected_count*/ 0, + ) + .await?; + assert!( + !codex_home + .path() + .join("plugins/cache/chatgpt-global/linear") + .exists() + ); Ok(()) } @@ -571,6 +676,60 @@ async fn plugin_install_tracks_analytics_event() -> Result<()> { Ok(()) } +#[tokio::test] +async fn plugin_install_errors_when_remote_bundle_download_fails() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + let bundle_url = mount_remote_plugin_bundle( + &server, + /*status_code*/ 503, + b"bundle temporarily unavailable".to_vec(), + ) + .await; + configure_remote_plugin_test(codex_home.path(), &server)?; + mount_remote_plugin_detail(&server, REMOTE_PLUGIN_ID, "1.2.3", Some(&bundle_url)).await; + mount_empty_remote_installed_plugins(&server).await; + mount_remote_plugin_install(&server, REMOTE_PLUGIN_ID).await; + + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))], + ) + .await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32603); + assert!(err.error.message.contains("failed with status 503")); + wait_for_remote_plugin_request_count( + &server, + "GET", + "/bundles/linear.tar.gz", + /*expected_count*/ 1, + ) + .await?; + wait_for_remote_plugin_request_count( + &server, + "POST", + &format!("/ps/plugins/{REMOTE_PLUGIN_ID}/install"), + /*expected_count*/ 0, + ) + .await?; + assert!( + !codex_home + .path() + .join("plugins/cache/chatgpt-global/linear") + .exists() + ); + Ok(()) +} + #[tokio::test] async fn plugin_install_returns_apps_needing_auth() -> Result<()> { let connectors = vec![ @@ -1014,6 +1173,152 @@ remote_plugin = true ) } +fn configure_remote_plugin_test(codex_home: &std::path::Path, server: &MockServer) -> Result<()> { + write_remote_plugin_catalog_config(codex_home, &format!("{}/backend-api/", server.uri()))?; + write_chatgpt_auth( + codex_home, + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + ) +} + +async fn mount_remote_plugin_bundle( + server: &MockServer, + status_code: u16, + body: Vec, +) -> String { + Mock::given(method("GET")) + .and(path("/bundles/linear.tar.gz")) + .respond_with( + ResponseTemplate::new(status_code) + .insert_header("content-type", "application/gzip") + .set_body_bytes(body), + ) + .mount(server) + .await; + format!("{}/bundles/linear.tar.gz", server.uri()) +} + +async fn mount_remote_plugin_detail( + server: &MockServer, + remote_plugin_id: &str, + release_version: &str, + bundle_download_url: Option<&str>, +) { + let bundle_download_url_field = bundle_download_url + .map(|url| format!(r#" "bundle_download_url": "{url}","#)) + .unwrap_or_default(); + let detail_body = format!( + r#"{{ + "id": "{remote_plugin_id}", + "name": "linear", + "scope": "GLOBAL", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "release": {{ + "version": "{release_version}", +{bundle_download_url_field} + "display_name": "Linear", + "description": "Track work in Linear", + "app_ids": [], + "interface": {{ + "short_description": "Plan and track work" + }}, + "skills": [] + }} +}}"# + ); + + Mock::given(method("GET")) + .and(path(format!("/backend-api/ps/plugins/{remote_plugin_id}"))) + .and(query_param("includeDownloadUrls", "true")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(detail_body)) + .mount(server) + .await; +} + +async fn mount_empty_remote_installed_plugins(server: &MockServer) { + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/installed")) + .and(query_param("scope", "GLOBAL")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#"{ + "plugins": [], + "pagination": { + "limit": 50, + "next_page_token": null + } +}"#, + )) + .mount(server) + .await; +} + +async fn mount_remote_plugin_install(server: &MockServer, remote_plugin_id: &str) { + Mock::given(method("POST")) + .and(path(format!( + "/backend-api/ps/plugins/{remote_plugin_id}/install" + ))) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(format!(r#"{{"id":"{remote_plugin_id}","enabled":true}}"#)), + ) + .mount(server) + .await; +} + +#[derive(Debug, Clone)] +struct CacheManifestExists { + manifest_path: std::path::PathBuf, +} + +impl Match for CacheManifestExists { + fn matches(&self, _request: &Request) -> bool { + self.manifest_path.is_file() + } +} + +async fn mount_remote_plugin_install_after_cache_write( + server: &MockServer, + remote_plugin_id: &str, + manifest_path: std::path::PathBuf, +) { + Mock::given(method("POST")) + .and(path(format!( + "/backend-api/ps/plugins/{remote_plugin_id}/install" + ))) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .and(CacheManifestExists { manifest_path }) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(format!(r#"{{"id":"{remote_plugin_id}","enabled":true}}"#)), + ) + .mount(server) + .await; +} + +async fn send_remote_plugin_install_request( + mcp: &mut McpProcess, + remote_plugin_id: &str, +) -> Result { + mcp.send_plugin_install_request(PluginInstallParams { + marketplace_path: None, + remote_marketplace_name: Some("chatgpt-global".to_string()), + plugin_name: remote_plugin_id.to_string(), + }) + .await +} + async fn wait_for_remote_plugin_request_count( server: &MockServer, method_name: &str, @@ -1115,3 +1420,29 @@ fn write_plugin_source( )?; Ok(()) } + +fn remote_plugin_bundle_tar_gz_bytes(plugin_name: &str) -> Result> { + let manifest = format!(r#"{{"name":"{plugin_name}"}}"#); + let skill = "# Plan Work\n\nTrack work in Linear.\n"; + let encoder = GzEncoder::new(Vec::new(), Compression::default()); + let mut tar = tar::Builder::new(encoder); + for (path, contents, mode) in [ + ( + ".codex-plugin/plugin.json", + manifest.as_bytes(), + /*mode*/ 0o644, + ), + ( + "skills/plan-work/SKILL.md", + skill.as_bytes(), + /*mode*/ 0o644, + ), + ] { + let mut header = tar::Header::new_gnu(); + header.set_size(contents.len() as u64); + header.set_mode(mode); + header.set_cksum(); + tar.append_data(&mut header, path, contents)?; + } + Ok(tar.into_inner()?.finish()?) +} diff --git a/codex-rs/app-server/tests/suite/v2/plugin_list.rs b/codex-rs/app-server/tests/suite/v2/plugin_list.rs index 8735c20ff6..23f6e54969 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -1131,7 +1131,7 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - let global_directory_body = r#"{ "plugins": [ { - "id": "plugins~Plugin_linear", + "id": "plugins~Plugin_00000000000000000000000000000000", "name": "linear", "scope": "GLOBAL", "installation_policy": "AVAILABLE", @@ -1165,7 +1165,7 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - let global_installed_body = r#"{ "plugins": [ { - "id": "plugins~Plugin_linear", + "id": "plugins~Plugin_00000000000000000000000000000000", "name": "linear", "scope": "GLOBAL", "installation_policy": "AVAILABLE", @@ -1255,7 +1255,10 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - Some("ChatGPT Plugins") ); assert_eq!(remote_marketplace.plugins.len(), 1); - assert_eq!(remote_marketplace.plugins[0].id, "plugins~Plugin_linear"); + assert_eq!( + remote_marketplace.plugins[0].id, + "plugins~Plugin_00000000000000000000000000000000" + ); assert_eq!(remote_marketplace.plugins[0].name, "linear"); assert_eq!(remote_marketplace.plugins[0].source, PluginSource::Remote); assert_eq!(remote_marketplace.plugins[0].installed, true); @@ -1314,7 +1317,7 @@ async fn plugin_list_remote_marketplace_replaces_local_marketplace_with_same_nam let global_directory_body = r#"{ "plugins": [ { - "id": "plugins~Plugin_linear", + "id": "plugins~Plugin_00000000000000000000000000000000", "name": "linear", "scope": "GLOBAL", "installation_policy": "AVAILABLE", diff --git a/codex-rs/app-server/tests/suite/v2/plugin_read.rs b/codex-rs/app-server/tests/suite/v2/plugin_read.rs index 5360c381d8..753468345f 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -161,7 +161,7 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() -> )?; let detail_body = r#"{ - "id": "plugins~Plugin_linear", + "id": "plugins~Plugin_00000000000000000000000000000000", "name": "linear", "scope": "GLOBAL", "installation_policy": "AVAILABLE", @@ -192,7 +192,7 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() -> let installed_body = r#"{ "plugins": [ { - "id": "plugins~Plugin_linear", + "id": "plugins~Plugin_00000000000000000000000000000000", "name": "linear", "scope": "GLOBAL", "installation_policy": "AVAILABLE", @@ -230,7 +230,9 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() -> }"#; Mock::given(method("GET")) - .and(path("/backend-api/ps/plugins/plugins~Plugin_linear")) + .and(path( + "/backend-api/ps/plugins/plugins~Plugin_00000000000000000000000000000000", + )) .and(header("authorization", "Bearer chatgpt-token")) .and(header("chatgpt-account-id", "account-123")) .respond_with(ResponseTemplate::new(200).set_body_string(detail_body)) @@ -252,7 +254,7 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() -> .send_plugin_read_request(PluginReadParams { marketplace_path: None, remote_marketplace_name: Some("chatgpt-global".to_string()), - plugin_name: "plugins~Plugin_linear".to_string(), + plugin_name: "plugins~Plugin_00000000000000000000000000000000".to_string(), }) .await?; @@ -266,7 +268,10 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() -> assert_eq!(response.plugin.marketplace_name, "chatgpt-global"); assert_eq!(response.plugin.marketplace_path, None); assert_eq!(response.plugin.summary.source, PluginSource::Remote); - assert_eq!(response.plugin.summary.id, "plugins~Plugin_linear"); + assert_eq!( + response.plugin.summary.id, + "plugins~Plugin_00000000000000000000000000000000" + ); assert_eq!(response.plugin.summary.name, "linear"); assert_eq!(response.plugin.summary.installed, true); assert_eq!(response.plugin.summary.enabled, false); diff --git a/codex-rs/core-plugins/Cargo.toml b/codex-rs/core-plugins/Cargo.toml index 8a0e4f7720..ee477a381f 100644 --- a/codex-rs/core-plugins/Cargo.toml +++ b/codex-rs/core-plugins/Cargo.toml @@ -27,9 +27,11 @@ codex-utils-absolute-path = { workspace = true } codex-utils-plugins = { workspace = true } chrono = { workspace = true } dirs = { workspace = true } +flate2 = { workspace = true } reqwest = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +tar = { workspace = true } tempfile = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["fs", "macros", "rt", "time"] } diff --git a/codex-rs/core-plugins/src/lib.rs b/codex-rs/core-plugins/src/lib.rs index 61e5ef3724..45ce391073 100644 --- a/codex-rs/core-plugins/src/lib.rs +++ b/codex-rs/core-plugins/src/lib.rs @@ -6,6 +6,7 @@ pub mod marketplace_add; pub mod marketplace_remove; pub mod marketplace_upgrade; pub mod remote; +pub mod remote_bundle; pub mod remote_legacy; pub mod startup_sync; pub mod store; diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index e453c52f97..8c447a1edb 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -49,6 +49,8 @@ pub struct RemotePluginDetail { pub marketplace_display_name: String, pub summary: RemotePluginSummary, pub description: Option, + pub release_version: Option, + pub bundle_download_url: Option, pub skills: Vec, pub app_ids: Vec, } @@ -168,23 +170,16 @@ impl RemotePluginScope { #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] struct RemotePluginPagination { - #[serde(alias = "nextPageToken")] next_page_token: Option, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] struct RemotePluginSkillInterfaceResponse { - #[serde(alias = "displayName")] display_name: Option, - #[serde(alias = "shortDescription")] short_description: Option, - #[serde(alias = "brandColor")] brand_color: Option, - #[serde(alias = "defaultPrompt")] default_prompt: Option, - #[serde(alias = "iconSmallUrl")] icon_small_url: Option, - #[serde(alias = "iconLargeUrl")] icon_large_url: Option, } @@ -197,41 +192,32 @@ struct RemotePluginSkillResponse { #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] struct RemotePluginReleaseInterfaceResponse { - #[serde(alias = "shortDescription")] short_description: Option, - #[serde(alias = "longDescription")] long_description: Option, - #[serde(alias = "developerName")] developer_name: Option, category: Option, #[serde(default)] capabilities: Vec, - #[serde(alias = "websiteUrl")] website_url: Option, - #[serde(alias = "privacyPolicyUrl")] privacy_policy_url: Option, - #[serde(alias = "termsOfServiceUrl")] terms_of_service_url: Option, - #[serde(alias = "brandColor")] brand_color: Option, - #[serde(alias = "defaultPrompt")] default_prompt: Option, - #[serde(alias = "composerIconUrl")] composer_icon_url: Option, - #[serde(alias = "logoUrl")] logo_url: Option, #[serde(default)] - #[serde(alias = "screenshotUrls")] screenshot_urls: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] struct RemotePluginReleaseResponse { - #[serde(alias = "displayName")] + #[serde(default)] + version: Option, display_name: String, description: String, #[serde(default)] - #[serde(alias = "appIds")] + bundle_download_url: Option, + #[serde(default)] app_ids: Vec, interface: RemotePluginReleaseInterfaceResponse, #[serde(default)] @@ -243,9 +229,7 @@ struct RemotePluginDirectoryItem { id: String, name: String, scope: RemotePluginScope, - #[serde(alias = "installationPolicy")] installation_policy: PluginInstallPolicy, - #[serde(alias = "authenticationPolicy")] authentication_policy: PluginAuthPolicy, release: RemotePluginReleaseResponse, } @@ -256,7 +240,6 @@ struct RemotePluginInstalledItem { plugin: RemotePluginDirectoryItem, enabled: bool, #[serde(default)] - #[serde(alias = "disabledSkillNames")] disabled_skill_names: Vec, } @@ -381,6 +364,39 @@ pub async fn fetch_remote_plugin_detail( auth: Option<&CodexAuth>, marketplace_name: &str, plugin_id: &str, +) -> Result { + fetch_remote_plugin_detail_with_download_url_option( + config, + auth, + marketplace_name, + plugin_id, + /*include_download_urls*/ false, + ) + .await +} + +pub async fn fetch_remote_plugin_detail_with_download_urls( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, + marketplace_name: &str, + plugin_id: &str, +) -> Result { + fetch_remote_plugin_detail_with_download_url_option( + config, + auth, + marketplace_name, + plugin_id, + /*include_download_urls*/ true, + ) + .await +} + +async fn fetch_remote_plugin_detail_with_download_url_option( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, + marketplace_name: &str, + plugin_id: &str, + include_download_urls: bool, ) -> Result { let auth = ensure_chatgpt_auth(auth)?; let scope = RemotePluginScope::from_marketplace_name(marketplace_name).ok_or_else(|| { @@ -388,7 +404,7 @@ pub async fn fetch_remote_plugin_detail( marketplace_name: marketplace_name.to_string(), } })?; - let plugin = fetch_plugin_detail(config, auth, plugin_id).await?; + let plugin = fetch_plugin_detail(config, auth, plugin_id, include_download_urls).await?; let actual_marketplace_name = plugin.scope.marketplace_name(); if actual_marketplace_name != marketplace_name { return Err(RemotePluginCatalogError::MarketplaceMismatch { @@ -433,6 +449,8 @@ pub async fn fetch_remote_plugin_detail( marketplace_display_name: scope.marketplace_display_name().to_string(), summary: build_remote_plugin_summary(&plugin, installed_plugin.as_ref()), description: non_empty_string(Some(&plugin.release.description)), + release_version: plugin.release.version, + bundle_download_url: plugin.release.bundle_download_url, skills, app_ids: plugin.release.app_ids, }) @@ -651,11 +669,15 @@ async fn fetch_plugin_detail( config: &RemotePluginServiceConfig, auth: &CodexAuth, plugin_id: &str, + include_download_urls: bool, ) -> Result { let base_url = config.chatgpt_base_url.trim_end_matches('/'); let url = format!("{base_url}/ps/plugins/{plugin_id}"); let client = build_reqwest_client(); - let request = authenticated_request(client.get(&url), auth)?; + let mut request = authenticated_request(client.get(&url), auth)?; + if include_download_urls { + request = request.query(&[("includeDownloadUrls", true)]); + } send_and_decode(request, &url).await } diff --git a/codex-rs/core-plugins/src/remote_bundle.rs b/codex-rs/core-plugins/src/remote_bundle.rs new file mode 100644 index 0000000000..92d5613846 --- /dev/null +++ b/codex-rs/core-plugins/src/remote_bundle.rs @@ -0,0 +1,848 @@ +use crate::store::PluginInstallResult; +use crate::store::PluginStore; +use crate::store::PluginStoreError; +use crate::store::validate_plugin_version_segment; +use codex_login::default_client::build_reqwest_client; +use codex_plugin::PluginId; +use codex_plugin::PluginIdError; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_plugins::find_plugin_manifest_path; +use flate2::read::GzDecoder; +use reqwest::Response; +use reqwest::StatusCode; +use std::fs; +use std::io; +use std::io::Read; +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; +use tar::Archive; +use url::Host; +use url::Url; + +const REMOTE_PLUGIN_BUNDLE_DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(60); +const REMOTE_PLUGIN_BUNDLE_MAX_DOWNLOAD_BYTES: u64 = 50 * 1024 * 1024; +const REMOTE_PLUGIN_BUNDLE_ERROR_BODY_MAX_BYTES: u64 = 8 * 1024; +const REMOTE_PLUGIN_BUNDLE_MAX_EXTRACTED_BYTES: u64 = 250 * 1024 * 1024; +const REMOTE_PLUGIN_INSTALL_STAGING_DIR: &str = "plugins/.remote-plugin-install-staging"; +#[cfg(debug_assertions)] +const TEST_ALLOW_LOOPBACK_HTTP_REMOTE_PLUGIN_BUNDLES_ENV: &str = + "CODEX_TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS"; + +#[derive(Debug, Clone)] +pub struct ValidatedRemotePluginBundle { + pub plugin_id: PluginId, + pub plugin_version: String, + bundle_download_url: String, +} + +#[derive(Debug, thiserror::Error)] +pub enum RemotePluginBundleInstallError { + #[error("backend did not return a release version for remote plugin `{remote_plugin_id}`")] + MissingReleaseVersion { remote_plugin_id: String }, + + #[error( + "backend returned an invalid release version for remote plugin `{remote_plugin_id}`: {message}" + )] + InvalidReleaseVersion { + remote_plugin_id: String, + message: String, + }, + + #[error("backend did not return a download URL for remote plugin `{remote_plugin_id}`")] + MissingBundleDownloadUrl { remote_plugin_id: String }, + + #[error( + "backend returned an invalid download URL for remote plugin `{remote_plugin_id}`: {url}" + )] + InvalidBundleDownloadUrl { + remote_plugin_id: String, + url: String, + #[source] + source: url::ParseError, + }, + + #[error( + "backend returned an unsupported download URL scheme for remote plugin `{remote_plugin_id}`: {scheme}" + )] + UnsupportedBundleDownloadUrlScheme { + remote_plugin_id: String, + scheme: String, + }, + + #[error( + "backend returned an invalid local plugin id for remote plugin `{remote_plugin_id}`: {source}" + )] + InvalidPluginId { + remote_plugin_id: String, + #[source] + source: PluginIdError, + }, + + #[error("failed to send remote plugin bundle download request to {url}: {source}")] + DownloadRequest { + url: String, + #[source] + source: reqwest::Error, + }, + + #[error("remote plugin bundle download from {url} failed with status {status}: {body}")] + DownloadStatus { + url: String, + status: StatusCode, + body: String, + }, + + #[error("failed to read remote plugin bundle download response from {url}: {source}")] + DownloadBody { + url: String, + #[source] + source: reqwest::Error, + }, + + #[error("remote plugin bundle download from {url} exceeded maximum size of {max_bytes} bytes")] + DownloadTooLarge { url: String, max_bytes: u64 }, + + #[error("remote plugin bundle download from {url} redirected to unsupported URL {final_url}")] + UnsupportedBundleDownloadFinalUrl { url: String, final_url: String }, + + #[error( + "remote plugin bundle extracted size would be {bytes} bytes, exceeding the maximum total size of {max_bytes} bytes" + )] + ExtractedBundleTooLarge { bytes: u64, max_bytes: u64 }, + + #[error("{context}: {source}")] + Io { + context: &'static str, + #[source] + source: io::Error, + }, + + #[error("{0}")] + InvalidBundle(String), + + #[error("{0}")] + Store(#[from] PluginStoreError), +} + +impl RemotePluginBundleInstallError { + fn io(context: &'static str, source: io::Error) -> Self { + Self::Io { context, source } + } +} + +pub fn validate_remote_plugin_bundle( + remote_plugin_id: &str, + remote_marketplace_name: &str, + plugin_name: &str, + release_version: Option<&str>, + bundle_download_url: Option<&str>, +) -> Result { + let plugin_id = PluginId::new(plugin_name.to_string(), remote_marketplace_name.to_string()) + .map_err(|source| RemotePluginBundleInstallError::InvalidPluginId { + remote_plugin_id: remote_plugin_id.to_string(), + source, + })?; + let plugin_version = release_version + .map(str::trim) + .filter(|version| !version.is_empty()) + .ok_or_else(|| RemotePluginBundleInstallError::MissingReleaseVersion { + remote_plugin_id: remote_plugin_id.to_string(), + })? + .to_string(); + validate_plugin_version_segment(&plugin_version).map_err(|message| { + RemotePluginBundleInstallError::InvalidReleaseVersion { + remote_plugin_id: remote_plugin_id.to_string(), + message, + } + })?; + let bundle_download_url = bundle_download_url + .map(str::trim) + .filter(|url| !url.is_empty()) + .ok_or_else( + || RemotePluginBundleInstallError::MissingBundleDownloadUrl { + remote_plugin_id: remote_plugin_id.to_string(), + }, + )? + .to_string(); + let parsed_bundle_url = Url::parse(&bundle_download_url).map_err(|source| { + RemotePluginBundleInstallError::InvalidBundleDownloadUrl { + remote_plugin_id: remote_plugin_id.to_string(), + url: bundle_download_url.clone(), + source, + } + })?; + if !is_allowed_bundle_download_url( + &parsed_bundle_url, + allow_test_loopback_http_bundle_downloads(), + ) { + return Err( + RemotePluginBundleInstallError::UnsupportedBundleDownloadUrlScheme { + remote_plugin_id: remote_plugin_id.to_string(), + scheme: parsed_bundle_url.scheme().to_string(), + }, + ); + } + + Ok(ValidatedRemotePluginBundle { + plugin_id, + plugin_version, + bundle_download_url, + }) +} + +fn allow_test_loopback_http_bundle_downloads() -> bool { + #[cfg(debug_assertions)] + { + if let Ok(value) = std::env::var(TEST_ALLOW_LOOPBACK_HTTP_REMOTE_PLUGIN_BUNDLES_ENV) { + return value == "1"; + } + } + + false +} + +fn is_allowed_bundle_download_url(url: &Url, allow_loopback_http: bool) -> bool { + match url.scheme() { + "https" => true, + "http" => allow_loopback_http && is_loopback_url(url), + _ => false, + } +} + +fn is_loopback_url(url: &Url) -> bool { + match url.host() { + Some(Host::Ipv4(addr)) => addr.is_loopback(), + Some(Host::Ipv6(addr)) => addr.is_loopback(), + Some(Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"), + None => false, + } +} + +pub async fn download_and_install_remote_plugin_bundle( + codex_home: PathBuf, + bundle: ValidatedRemotePluginBundle, +) -> Result { + let bundle_bytes = download_remote_plugin_bundle_with_limit( + &bundle.bundle_download_url, + /*max_bytes*/ REMOTE_PLUGIN_BUNDLE_MAX_DOWNLOAD_BYTES, + ) + .await?; + tokio::task::spawn_blocking(move || { + install_remote_plugin_bundle(codex_home, bundle, bundle_bytes) + }) + .await + .map_err(|err| { + RemotePluginBundleInstallError::InvalidBundle(format!( + "failed to join remote plugin bundle install task: {err}" + )) + })? +} + +async fn download_remote_plugin_bundle_with_limit( + bundle_download_url: &str, + max_bytes: u64, +) -> Result, RemotePluginBundleInstallError> { + let client = build_reqwest_client(); + let response = client + .get(bundle_download_url) + .timeout(REMOTE_PLUGIN_BUNDLE_DOWNLOAD_TIMEOUT) + .send() + .await + .map_err(|source| RemotePluginBundleInstallError::DownloadRequest { + url: bundle_download_url.to_string(), + source, + })?; + + let final_url = response.url().clone(); + // reqwest may already have followed redirects here. For backend-issued bundle URLs, keep the + // shared client policy and fail unsupported final schemes before caching. + if !is_allowed_bundle_download_url(&final_url, allow_test_loopback_http_bundle_downloads()) { + return Err( + RemotePluginBundleInstallError::UnsupportedBundleDownloadFinalUrl { + url: bundle_download_url.to_string(), + final_url: final_url.to_string(), + }, + ); + } + + let url = final_url.to_string(); + let status = response.status(); + if !status.is_success() { + let body = read_response_body_with_limit( + response, + &url, + /*max_bytes*/ REMOTE_PLUGIN_BUNDLE_ERROR_BODY_MAX_BYTES, + ) + .await?; + let body = String::from_utf8_lossy(&body).to_string(); + return Err(RemotePluginBundleInstallError::DownloadStatus { url, status, body }); + } + + read_response_body_with_limit(response, &url, max_bytes).await +} + +async fn read_response_body_with_limit( + mut response: Response, + url: &str, + max_bytes: u64, +) -> Result, RemotePluginBundleInstallError> { + if let Some(content_length) = response.content_length() { + enforce_download_size_limit(url, content_length, max_bytes)?; + } + + let mut body = Vec::new(); + while let Some(chunk) = + response + .chunk() + .await + .map_err(|source| RemotePluginBundleInstallError::DownloadBody { + url: url.to_string(), + source, + })? + { + let next_len = body.len() as u64 + chunk.len() as u64; + enforce_download_size_limit(url, next_len, max_bytes)?; + body.extend_from_slice(&chunk); + } + + Ok(body) +} + +fn enforce_download_size_limit( + url: &str, + bytes: u64, + max_bytes: u64, +) -> Result<(), RemotePluginBundleInstallError> { + if bytes > max_bytes { + return Err(RemotePluginBundleInstallError::DownloadTooLarge { + url: url.to_string(), + max_bytes, + }); + } + Ok(()) +} + +fn install_remote_plugin_bundle( + codex_home: PathBuf, + bundle: ValidatedRemotePluginBundle, + bundle_bytes: Vec, +) -> Result { + let staging_root = codex_home.join(REMOTE_PLUGIN_INSTALL_STAGING_DIR); + fs::create_dir_all(&staging_root).map_err(|source| { + RemotePluginBundleInstallError::io( + "failed to create remote plugin bundle staging directory", + source, + ) + })?; + let extract_dir = tempfile::Builder::new() + .prefix("remote-plugin-bundle-") + .tempdir_in(&staging_root) + .map_err(|source| { + RemotePluginBundleInstallError::io( + "failed to create remote plugin bundle extraction directory", + source, + ) + })?; + + extract_plugin_bundle_tar_gz(&bundle_bytes, extract_dir.path())?; + let plugin_root = find_extracted_plugin_root(extract_dir.path())?; + let plugin_root = AbsolutePathBuf::try_from(plugin_root).map_err(|err| { + RemotePluginBundleInstallError::InvalidBundle(format!( + "failed to resolve extracted remote plugin bundle root: {err}" + )) + })?; + + let store = PluginStore::try_new(codex_home)?; + store + .install_with_version(plugin_root, bundle.plugin_id, bundle.plugin_version) + .map_err(RemotePluginBundleInstallError::from) +} + +fn extract_plugin_bundle_tar_gz( + bytes: &[u8], + destination: &Path, +) -> Result<(), RemotePluginBundleInstallError> { + extract_plugin_bundle_tar_gz_with_limits( + bytes, + destination, + REMOTE_PLUGIN_BUNDLE_MAX_EXTRACTED_BYTES, + ) +} + +fn extract_plugin_bundle_tar_gz_with_limits( + bytes: &[u8], + destination: &Path, + max_total_bytes: u64, +) -> Result<(), RemotePluginBundleInstallError> { + fs::create_dir_all(destination).map_err(|source| { + RemotePluginBundleInstallError::io( + "failed to create remote plugin bundle extraction directory", + source, + ) + })?; + + let archive = GzDecoder::new(std::io::Cursor::new(bytes)); + let mut archive = Archive::new(archive); + extract_plugin_bundle_tar(&mut archive, destination, max_total_bytes) +} + +fn extract_plugin_bundle_tar( + archive: &mut Archive, + destination: &Path, + max_total_bytes: u64, +) -> Result<(), RemotePluginBundleInstallError> { + let mut extracted_bytes = 0u64; + let entries = archive.entries().map_err(|source| { + RemotePluginBundleInstallError::io("failed to read remote plugin bundle tar", source) + })?; + let entries = entries.raw(true); + for entry in entries { + let mut entry = entry.map_err(|source| { + RemotePluginBundleInstallError::io( + "failed to read remote plugin bundle tar entry", + source, + ) + })?; + let entry_type = entry.header().entry_type(); + let entry_size = entry.size(); + let entry_path = entry.path().map_err(|source| { + RemotePluginBundleInstallError::io( + "failed to read remote plugin bundle tar entry path", + source, + ) + })?; + let entry_path = entry_path.into_owned(); + let output_path = checked_tar_output_path(destination, &entry_path)?; + + if entry_type.is_dir() { + fs::create_dir_all(&output_path).map_err(|source| { + RemotePluginBundleInstallError::io( + "failed to create remote plugin bundle directory", + source, + ) + })?; + continue; + } + + if entry_type.is_file() { + enforce_total_extracted_size(entry_size, &mut extracted_bytes, max_total_bytes)?; + let Some(parent) = output_path.parent() else { + return Err(RemotePluginBundleInstallError::InvalidBundle(format!( + "remote plugin bundle output path has no parent: {}", + output_path.display() + ))); + }; + fs::create_dir_all(parent).map_err(|source| { + RemotePluginBundleInstallError::io( + "failed to create remote plugin bundle directory", + source, + ) + })?; + entry.unpack(&output_path).map_err(|source| { + RemotePluginBundleInstallError::io( + "failed to unpack remote plugin bundle entry", + source, + ) + })?; + continue; + } + + if entry_type.is_hard_link() || entry_type.is_symlink() { + return Err(RemotePluginBundleInstallError::InvalidBundle(format!( + "remote plugin bundle tar entry `{}` is a link", + entry_path.display() + ))); + } + + return Err(RemotePluginBundleInstallError::InvalidBundle(format!( + "remote plugin bundle tar entry `{}` has unsupported type {:?}", + entry_path.display(), + entry_type + ))); + } + + Ok(()) +} + +fn checked_tar_output_path( + destination: &Path, + entry_name: &Path, +) -> Result { + let mut output_path = destination.to_path_buf(); + let mut has_component = false; + for component in entry_name.components() { + match component { + std::path::Component::Normal(component) => { + has_component = true; + output_path.push(component); + } + std::path::Component::CurDir => {} + std::path::Component::ParentDir + | std::path::Component::RootDir + | std::path::Component::Prefix(_) => { + return Err(RemotePluginBundleInstallError::InvalidBundle(format!( + "remote plugin bundle tar entry `{}` escapes extraction root", + entry_name.display() + ))); + } + } + } + if !has_component { + return Err(RemotePluginBundleInstallError::InvalidBundle( + "remote plugin bundle tar entry has an empty path".to_string(), + )); + } + Ok(output_path) +} + +fn enforce_total_extracted_size( + entry_size: u64, + extracted_bytes: &mut u64, + max_total_bytes: u64, +) -> Result<(), RemotePluginBundleInstallError> { + let next_total = extracted_bytes.checked_add(entry_size).ok_or( + RemotePluginBundleInstallError::ExtractedBundleTooLarge { + bytes: u64::MAX, + max_bytes: max_total_bytes, + }, + )?; + if next_total > max_total_bytes { + return Err(RemotePluginBundleInstallError::ExtractedBundleTooLarge { + bytes: next_total, + max_bytes: max_total_bytes, + }); + } + *extracted_bytes = next_total; + Ok(()) +} + +fn find_extracted_plugin_root( + extraction_root: &Path, +) -> Result { + if is_standard_plugin_root(extraction_root) { + return Ok(extraction_root.to_path_buf()); + } + + Err(RemotePluginBundleInstallError::InvalidBundle( + "remote plugin bundle did not contain a standard plugin root with plugin.json".to_string(), + )) +} + +fn is_standard_plugin_root(path: &Path) -> bool { + find_plugin_manifest_path(path).is_some() +} + +#[cfg(test)] +mod tests { + use super::*; + use flate2::Compression; + use flate2::write::GzEncoder; + use pretty_assertions::assert_eq; + use tempfile::tempdir; + + const REMOTE_PLUGIN_ID: &str = "plugins~Plugin_00000000000000000000000000000000"; + + #[test] + fn validate_remote_plugin_bundle_uses_detail_name_for_local_plugin_id() { + let bundle = validate_remote_plugin_bundle( + REMOTE_PLUGIN_ID, + "chatgpt-global", + "linear", + Some("1.2.3"), + Some("https://example.com/linear.tar.gz"), + ) + .expect("valid install plan"); + + assert_eq!(bundle.plugin_id.plugin_name, "linear"); + assert_eq!(bundle.plugin_id.marketplace_name, "chatgpt-global"); + assert_eq!(bundle.plugin_version, "1.2.3"); + assert_eq!( + bundle.bundle_download_url.as_str(), + "https://example.com/linear.tar.gz" + ); + } + + #[test] + fn validate_remote_plugin_bundle_rejects_missing_release_version() { + let err = validate_remote_plugin_bundle( + REMOTE_PLUGIN_ID, + "chatgpt-global", + "linear", + /*release_version*/ None, + Some("https://example.com/linear.tar.gz"), + ) + .expect_err("missing release version should be rejected"); + + assert!(matches!( + err, + RemotePluginBundleInstallError::MissingReleaseVersion { .. } + )); + } + + #[test] + fn validate_remote_plugin_bundle_rejects_invalid_release_version() { + let err = validate_remote_plugin_bundle( + REMOTE_PLUGIN_ID, + "chatgpt-global", + "linear", + Some("../1.2.3"), + Some("https://example.com/linear.tar.gz"), + ) + .expect_err("invalid release version should be rejected"); + + assert!(matches!( + err, + RemotePluginBundleInstallError::InvalidReleaseVersion { .. } + )); + } + + #[test] + fn validate_remote_plugin_bundle_rejects_missing_download_url() { + let err = validate_remote_plugin_bundle( + REMOTE_PLUGIN_ID, + "chatgpt-global", + "linear", + Some("1.2.3"), + /*bundle_download_url*/ None, + ) + .expect_err("missing bundle download URL should be rejected"); + + assert!(matches!( + err, + RemotePluginBundleInstallError::MissingBundleDownloadUrl { .. } + )); + } + + #[test] + fn validate_remote_plugin_bundle_rejects_unsupported_download_url_scheme() { + let err = validate_remote_plugin_bundle( + REMOTE_PLUGIN_ID, + "chatgpt-global", + "linear", + Some("1.2.3"), + Some("http://example.com/linear.tar.gz"), + ) + .expect_err("plain HTTP URLs should be rejected before cloud install"); + + assert!(matches!( + err, + RemotePluginBundleInstallError::UnsupportedBundleDownloadUrlScheme { .. } + )); + } + + #[test] + fn download_size_limit_rejects_oversized_bundle() { + let err = enforce_download_size_limit( + "https://example.com/linear.tar.gz", + /*bytes*/ 5, + /*max_bytes*/ 4, + ) + .expect_err("oversized bundle download should fail"); + + assert!(matches!( + err, + RemotePluginBundleInstallError::DownloadTooLarge { .. } + )); + } + + #[test] + fn install_rejects_invalid_tar_gz_bundle() { + let codex_home = tempdir().expect("tempdir"); + let bundle = valid_remote_plugin_bundle(); + + let err = install_remote_plugin_bundle( + codex_home.path().to_path_buf(), + bundle, + b"not a tar.gz".to_vec(), + ) + .expect_err("invalid tar.gz should be rejected"); + + assert!(format!("{err}").contains("failed to read remote plugin bundle tar")); + } + + #[test] + fn install_rejects_bundle_without_standard_plugin_root() { + let codex_home = tempdir().expect("tempdir"); + let bundle = valid_remote_plugin_bundle(); + + let err = install_remote_plugin_bundle( + codex_home.path().to_path_buf(), + bundle, + tar_gz_bytes(&[("README.md", b"missing plugin manifest", /*mode*/ 0o644)]), + ) + .expect_err("bundle without plugin root should be rejected"); + + assert!( + format!("{err}").contains("did not contain a standard plugin root with plugin.json") + ); + } + + #[test] + fn find_extracted_plugin_root_uses_local_manifest_discovery() { + let extraction_root = tempdir().expect("tempdir"); + std::fs::create_dir_all(extraction_root.path().join(".codex-plugin")) + .expect("create manifest dir"); + std::fs::write( + extraction_root.path().join(".codex-plugin/plugin.json"), + r#"{"name":"linear"}"#, + ) + .expect("write manifest"); + + assert_eq!( + find_extracted_plugin_root(extraction_root.path()).expect("plugin root"), + extraction_root.path() + ); + } + + #[test] + fn find_extracted_plugin_root_rejects_nested_plugin_root() { + let extraction_root = tempdir().expect("tempdir"); + let plugin_root = extraction_root.path().join("linear"); + std::fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create manifest dir"); + std::fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"linear"}"#, + ) + .expect("write manifest"); + + let err = find_extracted_plugin_root(extraction_root.path()) + .expect_err("nested plugin root should be rejected"); + + assert!( + format!("{err}").contains("did not contain a standard plugin root with plugin.json") + ); + } + + #[test] + fn extraction_rejects_tar_path_traversal() { + let destination = tempdir().expect("tempdir"); + let err = checked_tar_output_path(destination.path(), Path::new("../evil.txt")) + .expect_err("tar path traversal should be rejected"); + + assert!(format!("{err}").contains("escapes extraction root")); + } + + #[test] + fn extraction_rejects_total_size_over_limit() { + let destination = tempdir().expect("tempdir"); + let err = extract_plugin_bundle_tar_gz_with_limits( + &tar_gz_bytes(&[ + ("a.txt", b"1234", /*mode*/ 0o644), + ("b.txt", b"5678", /*mode*/ 0o644), + ]), + destination.path(), + /*max_total_bytes*/ 6, + ) + .expect_err("oversized extracted bundle should be rejected"); + + assert!(matches!( + err, + RemotePluginBundleInstallError::ExtractedBundleTooLarge { .. } + )); + } + + #[test] + fn extraction_rejects_pax_metadata_entries() { + let destination = tempdir().expect("tempdir"); + let err = extract_plugin_bundle_tar_gz( + &tar_gz_bytes_with_entry_type( + tar::EntryType::XHeader, + "PaxHeaders.0/linear", + b"18 path=linear\n", + /*mode*/ 0o644, + ), + destination.path(), + ) + .expect_err("pax metadata entries should be rejected"); + + assert!(format!("{err}").contains("unsupported type")); + } + + #[cfg(unix)] + #[test] + fn extraction_preserves_executable_permissions() { + use std::os::unix::fs::PermissionsExt; + + let destination = tempdir().expect("tempdir"); + extract_plugin_bundle_tar_gz( + &tar_gz_bytes(&[ + ( + ".codex-plugin/plugin.json", + b"{\"name\":\"linear\"}", + /*mode*/ 0o644, + ), + ("bin/helper", b"#!/bin/sh\n", /*mode*/ 0o755), + ]), + destination.path(), + ) + .expect("extract bundle"); + + let mode = std::fs::metadata(destination.path().join("bin/helper")) + .expect("helper metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o755); + } + + fn valid_remote_plugin_bundle() -> ValidatedRemotePluginBundle { + validate_remote_plugin_bundle( + REMOTE_PLUGIN_ID, + "chatgpt-global", + "linear", + Some("1.2.3"), + Some("https://example.com/linear.tar.gz"), + ) + .expect("valid install plan") + } + + fn tar_gz_bytes(entries: &[(&str, &[u8], u32)]) -> Vec { + let encoder = GzEncoder::new(Vec::new(), Compression::default()); + let mut tar = tar::Builder::new(encoder); + for (path, contents, mode) in entries { + append_tar_entry(&mut tar, tar::EntryType::Regular, path, contents, *mode); + } + finish_tar_gz(tar) + } + + fn tar_gz_bytes_with_entry_type( + entry_type: tar::EntryType, + path: &str, + contents: &[u8], + mode: u32, + ) -> Vec { + let encoder = GzEncoder::new(Vec::new(), Compression::default()); + let mut tar = tar::Builder::new(encoder); + append_tar_entry(&mut tar, entry_type, path, contents, mode); + finish_tar_gz(tar) + } + + fn append_tar_entry( + tar: &mut tar::Builder, + entry_type: tar::EntryType, + path: &str, + contents: &[u8], + mode: u32, + ) { + let mut header = tar::Header::new_gnu(); + header.set_entry_type(entry_type); + header.set_size(contents.len() as u64); + header.set_mode(mode); + header.set_cksum(); + if let Err(error) = tar.append_data(&mut header, path, contents) { + panic!("failed to append tar test data: {error}"); + } + } + + fn finish_tar_gz(tar: tar::Builder>>) -> Vec { + let encoder = match tar.into_inner() { + Ok(encoder) => encoder, + Err(error) => panic!("failed to finish tar test data: {error}"), + }; + match encoder.finish() { + Ok(bytes) => bytes, + Err(error) => panic!("failed to finish gzip test data: {error}"), + } + } +} diff --git a/codex-rs/core-plugins/src/store.rs b/codex-rs/core-plugins/src/store.rs index 757aec8bc5..2ffead0fbc 100644 --- a/codex-rs/core-plugins/src/store.rs +++ b/codex-rs/core-plugins/src/store.rs @@ -160,7 +160,7 @@ pub fn plugin_version_for_source(source_path: &Path) -> Result Result<(), String> { +pub fn validate_plugin_version_segment(plugin_version: &str) -> Result<(), String> { if plugin_version.is_empty() { return Err("invalid plugin version: must not be empty".to_string()); } From 803705f7957713732f4777bfd539388735349933 Mon Sep 17 00:00:00 2001 From: xli-oai Date: Tue, 28 Apr 2026 03:27:53 -0700 Subject: [PATCH 03/53] Add remote plugin uninstall API (#19456) ## Summary - Adds the remote `plugin/uninstall` request form using required `pluginId` plus optional `remoteMarketplaceName`, while preserving local `pluginId` uninstall. - Adds `codex_core_plugins::remote::uninstall_remote_plugin` for the deployed ChatGPT plugin backend uninstall path and validates the backend returns the same id with `enabled: false`. - Routes app-server remote uninstall through feature checks, remote plugin id validation, backend mutation, local downloaded cache deletion, cache clearing, docs, and regenerated protocol schemas. ## Tests - `just write-app-server-schema` - `just fmt` - `cargo test -p codex-app-server-protocol plugin_uninstall_params_serialization_omits_force_remote_sync` - `cargo test -p codex-app-server plugin_uninstall --test all` - `cargo test -p codex-app-server plugin_uninstall` - `cargo build -p codex-cli` - `CODEX_BIN=/Users/xli/code/codex/codex-rs/target/debug/codex python3 /Users/xli/.codex/skills/xli-test-marketplace-api/scripts/run_marketplace_api_matrix.py` (44 pass / 0 fail) - `just fix -p codex-app-server-protocol -p codex-app-server -p codex-tui` - `just fix -p codex-app-server` --- .../app-server-protocol/src/protocol/v2.rs | 21 + codex-rs/app-server/README.md | 2 +- .../src/codex_message_processor/plugins.rs | 67 ++- .../tests/suite/v2/plugin_uninstall.rs | 448 ++++++++++++++++++ codex-rs/core-plugins/src/remote.rs | 175 ++++++- 5 files changed, 701 insertions(+), 12 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index bfb1eaac07..d59363df44 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -10305,6 +10305,27 @@ mod tests { plugin_id: "gmail@openai-curated".to_string(), }, ); + + assert_eq!( + serde_json::to_value(PluginUninstallParams { + plugin_id: "plugins~Plugin_gmail".to_string(), + }) + .unwrap(), + json!({ + "pluginId": "plugins~Plugin_gmail", + }), + ); + + assert_eq!( + serde_json::from_value::(json!({ + "pluginId": "plugins~Plugin_gmail", + "forceRemoteSync": true, + })) + .unwrap(), + PluginUninstallParams { + plugin_id: "plugins~Plugin_gmail".to_string(), + }, + ); } #[test] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 639bfce5c9..2f968789e4 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -207,7 +207,7 @@ Example with notification opt-out: - `device/key/sign` — sign one of the accepted structured payload variants with a controller-local device key. The only accepted payload today is `remoteControlClientConnection`, which binds a server-issued `/client` websocket challenge to the enrolled controller device without signing the bearer token itself; this is intentionally not an arbitrary-byte signing API. - `skills/config/write` — write user-level skill config by name or absolute path. - `plugin/install` — install a plugin from a discovered marketplace entry, rejecting marketplace entries marked unavailable for install, install MCPs if any, and return the effective plugin auth policy plus any apps that still need auth (**under development; do not call from production clients yet**). -- `plugin/uninstall` — uninstall a plugin by id by removing its cached files and clearing its user-level config entry (**under development; do not call from production clients yet**). +- `plugin/uninstall` — uninstall a local plugin by `pluginId` in `@` form by removing its cached files and clearing its user-level config entry, or uninstall a remote ChatGPT plugin by backend `pluginId` by forwarding the uninstall to the ChatGPT plugin backend and removing any downloaded remote-plugin cache (**under development; do not call from production clients yet**). - `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes. - `tool/requestUserInput` — prompt the user with 1–3 short questions for a tool call and return their answers (experimental). - `config/mcpServer/reload` — reload MCP server config from disk and queue a refresh for loaded threads (applied on each thread's next active turn); returns `{}`. Use this after editing `config.toml` without restarting the server. diff --git a/codex-rs/app-server/src/codex_message_processor/plugins.rs b/codex-rs/app-server/src/codex_message_processor/plugins.rs index 11709d8e3f..db8ade8c67 100644 --- a/codex-rs/app-server/src/codex_message_processor/plugins.rs +++ b/codex-rs/app-server/src/codex_message_processor/plugins.rs @@ -407,11 +407,7 @@ impl CodexMessageProcessor { "remote plugin install is not enabled for marketplace {remote_marketplace_name}" ))); } - if plugin_name.is_empty() - || !plugin_name - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '~') - { + if plugin_name.is_empty() || !is_valid_remote_plugin_id(&plugin_name) { return Err(invalid_request( "invalid remote plugin id: only ASCII letters, digits, `_`, `-`, and `~` are allowed", )); @@ -579,6 +575,16 @@ impl CodexMessageProcessor { params: PluginUninstallParams, ) -> Result { let PluginUninstallParams { plugin_id } = params; + if codex_core::plugins::PluginId::parse(&plugin_id).is_err() + && !is_valid_remote_uninstall_plugin_id(&plugin_id) + { + return Err(invalid_request( + "invalid plugin id: expected a local plugin id in the form `plugin@marketplace` or a remote plugin id starting with `plugins~`, `app_`, `asdk_app_`, or `connector_`", + )); + } + if is_valid_remote_uninstall_plugin_id(&plugin_id) { + return self.remote_plugin_uninstall_response(plugin_id).await; + } let plugins_manager = self.thread_manager.plugins_manager(); plugins_manager @@ -648,6 +654,54 @@ impl CodexMessageProcessor { MarketplaceError::Io { .. } => internal_error(format!("failed to {action}: {err}")), } } + + async fn remote_plugin_uninstall_response( + &self, + plugin_id: String, + ) -> Result { + let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + if !config.features.enabled(Feature::Plugins) + || !config.features.enabled(Feature::RemotePlugin) + { + return Err(invalid_request("remote plugin uninstall is not enabled")); + } + if plugin_id.is_empty() || !is_valid_remote_plugin_id(&plugin_id) { + return Err(invalid_request( + "invalid remote plugin id: only ASCII letters, digits, `_`, `-`, and `~` are allowed", + )); + } + + let auth = self.auth_manager.auth().await; + let remote_plugin_service_config = RemotePluginServiceConfig { + chatgpt_base_url: config.chatgpt_base_url.clone(), + }; + codex_core_plugins::remote::uninstall_remote_plugin( + &remote_plugin_service_config, + auth.as_ref(), + config.codex_home.to_path_buf(), + &plugin_id, + ) + .await + .map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "uninstall remote plugin"))?; + + self.clear_plugin_related_caches(); + Ok(PluginUninstallResponse {}) + } +} + +fn is_valid_remote_plugin_id(plugin_name: &str) -> bool { + plugin_name + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '~') +} + +fn is_valid_remote_uninstall_plugin_id(plugin_name: &str) -> bool { + !plugin_name.is_empty() + && is_valid_remote_plugin_id(plugin_name) + && (plugin_name.starts_with("plugins~") + || plugin_name.starts_with("app_") + || plugin_name.starts_with("asdk_app_") + || plugin_name.starts_with("connector_")) } fn remote_marketplace_to_info(marketplace: RemoteMarketplace) -> PluginMarketplaceEntry { @@ -734,7 +788,8 @@ fn remote_plugin_catalog_error_to_jsonrpc( | RemotePluginCatalogError::UnexpectedStatus { .. } | RemotePluginCatalogError::Decode { .. } | RemotePluginCatalogError::UnexpectedPluginId { .. } - | RemotePluginCatalogError::UnexpectedEnabledState { .. } => JSONRPCErrorError { + | RemotePluginCatalogError::UnexpectedEnabledState { .. } + | RemotePluginCatalogError::CacheRemove(_) => JSONRPCErrorError { code: INTERNAL_ERROR_CODE, message: format!("{context}: {err}"), data: None, diff --git a/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs b/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs index 512cce3994..e0f71f11b5 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs @@ -1,6 +1,7 @@ use std::time::Duration; use anyhow::Result; +use anyhow::bail; use app_test_support::ChatGptAuthFixture; use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; @@ -16,8 +17,15 @@ use pretty_assertions::assert_eq; use serde_json::json; use tempfile::TempDir; use tokio::time::timeout; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); +const REMOTE_PLUGIN_ID: &str = "plugins~Plugin_linear"; #[tokio::test] async fn plugin_uninstall_removes_plugin_cache_and_config_entry() -> Result<()> { @@ -143,6 +151,340 @@ async fn plugin_uninstall_tracks_analytics_event() -> Result<()> { Ok(()) } +#[tokio::test] +async fn plugin_uninstall_rejects_remote_plugin_when_remote_plugin_is_disabled() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_uninstall_request(PluginUninstallParams { + plugin_id: "plugins~Plugin_sample".to_string(), + }) + .await?; + + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!( + err.error + .message + .contains("remote plugin uninstall is not enabled") + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_uninstall_writes_remote_plugin_to_cloud_when_remote_plugin_enabled() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_catalog_config( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + mount_remote_plugin_detail(&server, REMOTE_PLUGIN_ID, "1.0.0", "GLOBAL").await; + mount_empty_remote_installed_plugins(&server).await; + + Mock::given(method("POST")) + .and(path(format!( + "/backend-api/plugins/{REMOTE_PLUGIN_ID}/uninstall" + ))) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(format!(r#"{{"id":"{REMOTE_PLUGIN_ID}","enabled":false}}"#)), + ) + .mount(&server) + .await; + + let remote_plugin_cache_root = codex_home + .path() + .join("plugins/cache/chatgpt-global/linear"); + std::fs::create_dir_all(remote_plugin_cache_root.join("1.0.0/.codex-plugin"))?; + std::fs::write( + remote_plugin_cache_root.join("1.0.0/.codex-plugin/plugin.json"), + r#"{"name":"linear","version":"1.0.0"}"#, + )?; + let legacy_remote_plugin_cache_root = codex_home + .path() + .join(format!("plugins/cache/chatgpt-global/{REMOTE_PLUGIN_ID}")); + std::fs::create_dir_all(legacy_remote_plugin_cache_root.join("local/.codex-plugin"))?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_uninstall_request(PluginUninstallParams { + plugin_id: REMOTE_PLUGIN_ID.to_string(), + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginUninstallResponse = to_response(response)?; + + assert_eq!(response, PluginUninstallResponse {}); + wait_for_remote_plugin_request_count( + &server, + "POST", + &format!("/plugins/{REMOTE_PLUGIN_ID}/uninstall"), + /*expected_count*/ 1, + ) + .await?; + assert!(!remote_plugin_cache_root.exists()); + assert!(!legacy_remote_plugin_cache_root.exists()); + Ok(()) +} + +#[tokio::test] +async fn plugin_uninstall_uses_detail_scope_for_cache_namespace() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_catalog_config( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + mount_remote_plugin_detail(&server, REMOTE_PLUGIN_ID, "1.0.0", "WORKSPACE").await; + mount_empty_remote_installed_plugins(&server).await; + + Mock::given(method("POST")) + .and(path(format!( + "/backend-api/plugins/{REMOTE_PLUGIN_ID}/uninstall" + ))) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(format!(r#"{{"id":"{REMOTE_PLUGIN_ID}","enabled":false}}"#)), + ) + .mount(&server) + .await; + + let workspace_cache_root = codex_home + .path() + .join("plugins/cache/chatgpt-workspace/linear"); + std::fs::create_dir_all(workspace_cache_root.join("1.0.0/.codex-plugin"))?; + std::fs::write( + workspace_cache_root.join("1.0.0/.codex-plugin/plugin.json"), + r#"{"name":"linear","version":"1.0.0"}"#, + )?; + let global_cache_root = codex_home + .path() + .join("plugins/cache/chatgpt-global/linear"); + std::fs::create_dir_all(global_cache_root.join("1.0.0/.codex-plugin"))?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_uninstall_request(PluginUninstallParams { + plugin_id: REMOTE_PLUGIN_ID.to_string(), + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginUninstallResponse = to_response(response)?; + + assert_eq!(response, PluginUninstallResponse {}); + wait_for_remote_plugin_request_count( + &server, + "POST", + &format!("/plugins/{REMOTE_PLUGIN_ID}/uninstall"), + /*expected_count*/ 1, + ) + .await?; + assert!(!workspace_cache_root.exists()); + assert!(global_cache_root.exists()); + Ok(()) +} + +#[tokio::test] +async fn plugin_uninstall_posts_even_when_remote_detail_fetch_fails() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_catalog_config( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + Mock::given(method("POST")) + .and(path(format!( + "/backend-api/plugins/{REMOTE_PLUGIN_ID}/uninstall" + ))) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(format!(r#"{{"id":"{REMOTE_PLUGIN_ID}","enabled":false}}"#)), + ) + .mount(&server) + .await; + + let legacy_remote_plugin_cache_root = codex_home + .path() + .join(format!("plugins/cache/chatgpt-global/{REMOTE_PLUGIN_ID}")); + std::fs::create_dir_all(legacy_remote_plugin_cache_root.join("local/.codex-plugin"))?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_uninstall_request(PluginUninstallParams { + plugin_id: REMOTE_PLUGIN_ID.to_string(), + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginUninstallResponse = to_response(response)?; + + assert_eq!(response, PluginUninstallResponse {}); + wait_for_remote_plugin_request_count( + &server, + "POST", + &format!("/plugins/{REMOTE_PLUGIN_ID}/uninstall"), + /*expected_count*/ 1, + ) + .await?; + assert!(!legacy_remote_plugin_cache_root.exists()); + Ok(()) +} + +#[tokio::test] +async fn plugin_uninstall_rejects_malformed_local_plugin_id_before_remote_path() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_catalog_config( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_uninstall_request(PluginUninstallParams { + plugin_id: "sample-plugin".to_string(), + }) + .await?; + + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!(err.error.message.contains("invalid plugin id")); + wait_for_remote_plugin_request_count( + &server, + "POST", + "/plugins/sample-plugin/uninstall", + /*expected_count*/ 0, + ) + .await?; + Ok(()) +} + +#[tokio::test] +async fn plugin_uninstall_rejects_invalid_remote_plugin_id_before_network_call() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_catalog_config( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_uninstall_request(PluginUninstallParams { + plugin_id: "linear/../../oops".to_string(), + }) + .await?; + + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!(err.error.message.contains("invalid plugin id")); + wait_for_remote_plugin_request_count( + &server, + "POST", + "/plugins/linear/../../oops/uninstall", + /*expected_count*/ 0, + ) + .await?; + Ok(()) +} + +#[tokio::test] +async fn plugin_uninstall_rejects_empty_remote_plugin_id() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_catalog_config( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_uninstall_request(PluginUninstallParams { + plugin_id: String::new(), + }) + .await?; + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!(err.error.message.contains("invalid plugin id")); + + Ok(()) +} + fn write_installed_plugin( codex_home: &TempDir, marketplace_name: &str, @@ -161,3 +503,109 @@ fn write_installed_plugin( )?; Ok(()) } + +fn write_remote_plugin_catalog_config( + codex_home: &std::path::Path, + base_url: &str, +) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!( + r#" +chatgpt_base_url = "{base_url}" + +[features] +plugins = true +remote_plugin = true +"# + ), + ) +} + +async fn mount_remote_plugin_detail( + server: &MockServer, + remote_plugin_id: &str, + release_version: &str, + scope: &str, +) { + let detail_body = format!( + r#"{{ + "id": "{remote_plugin_id}", + "name": "linear", + "scope": "{scope}", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "release": {{ + "version": "{release_version}", + "display_name": "Linear", + "description": "Track work in Linear", + "app_ids": [], + "interface": {{ + "short_description": "Plan and track work" + }}, + "skills": [] + }} +}}"# + ); + + Mock::given(method("GET")) + .and(path(format!("/backend-api/ps/plugins/{remote_plugin_id}"))) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(detail_body)) + .mount(server) + .await; +} + +async fn mount_empty_remote_installed_plugins(server: &MockServer) { + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/installed")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#"{ + "plugins": [], + "pagination": { + "limit": 50, + "next_page_token": null + } +}"#, + )) + .mount(server) + .await; +} + +async fn wait_for_remote_plugin_request_count( + server: &MockServer, + method_name: &str, + path_suffix: &str, + expected_count: usize, +) -> Result<()> { + timeout(DEFAULT_TIMEOUT, async { + loop { + let Some(requests) = server.received_requests().await else { + if expected_count == 0 { + return Ok::<(), anyhow::Error>(()); + } + bail!("wiremock did not record requests"); + }; + let request_count = requests + .iter() + .filter(|request| { + request.method == method_name && request.url.path().ends_with(path_suffix) + }) + .count(); + if request_count == expected_count { + return Ok::<(), anyhow::Error>(()); + } + if request_count > expected_count { + bail!( + "expected exactly {expected_count} {method_name} {path_suffix} requests, got {request_count}" + ); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await??; + Ok(()) +} diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index 8c447a1edb..6c87f4dc30 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -1,15 +1,22 @@ +use crate::store::PLUGINS_CACHE_DIR; +use crate::store::PluginStore; use codex_app_server_protocol::PluginAuthPolicy; use codex_app_server_protocol::PluginInstallPolicy; use codex_app_server_protocol::PluginInterface; use codex_app_server_protocol::SkillInterface; use codex_login::CodexAuth; use codex_login::default_client::build_reqwest_client; +use codex_plugin::PluginId; use reqwest::RequestBuilder; use serde::Deserialize; use std::collections::BTreeMap; use std::collections::BTreeSet; use std::collections::HashSet; +use std::fs; +use std::path::Path; +use std::path::PathBuf; use std::time::Duration; +use tracing::warn; pub const REMOTE_GLOBAL_MARKETPLACE_NAME: &str = "chatgpt-global"; pub const REMOTE_WORKSPACE_MARKETPLACE_NAME: &str = "chatgpt-workspace"; @@ -111,18 +118,21 @@ pub enum RemotePluginCatalogError { }, #[error( - "remote plugin install returned unexpected plugin id: expected `{expected}`, got `{actual}`" + "remote plugin mutation returned unexpected plugin id: expected `{expected}`, got `{actual}`" )] UnexpectedPluginId { expected: String, actual: String }, #[error( - "remote plugin install returned unexpected enabled state for `{plugin_id}`: expected {expected_enabled}, got {actual_enabled}" + "remote plugin mutation returned unexpected enabled state for `{plugin_id}`: expected {expected_enabled}, got {actual_enabled}" )] UnexpectedEnabledState { plugin_id: String, expected_enabled: bool, actual_enabled: bool, }, + + #[error("{0}")] + CacheRemove(String), } #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize)] @@ -256,7 +266,7 @@ struct RemotePluginInstalledResponse { } #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] -struct RemotePluginInstallResponse { +struct RemotePluginMutationResponse { id: String, enabled: bool, } @@ -414,6 +424,46 @@ async fn fetch_remote_plugin_detail_with_download_url_option( }); } + build_remote_plugin_detail( + config, + auth, + scope, + marketplace_name.to_string(), + plugin_id, + plugin, + ) + .await +} + +async fn fetch_remote_plugin_detail_by_id( + config: &RemotePluginServiceConfig, + auth: &CodexAuth, + plugin_id: &str, +) -> Result { + let plugin = fetch_plugin_detail( + config, auth, plugin_id, /*include_download_urls*/ false, + ) + .await?; + let scope = plugin.scope; + build_remote_plugin_detail( + config, + auth, + scope, + scope.marketplace_name().to_string(), + plugin_id, + plugin, + ) + .await +} + +async fn build_remote_plugin_detail( + config: &RemotePluginServiceConfig, + auth: &CodexAuth, + scope: RemotePluginScope, + marketplace_name: String, + plugin_id: &str, + plugin: RemotePluginDirectoryItem, +) -> Result { let installed_plugin = fetch_installed_plugins_for_scope(config, auth, scope) .await? .into_iter() @@ -445,7 +495,7 @@ async fn fetch_remote_plugin_detail_with_download_url_option( .collect(); Ok(RemotePluginDetail { - marketplace_name: marketplace_name.to_string(), + marketplace_name, marketplace_display_name: scope.marketplace_display_name().to_string(), summary: build_remote_plugin_summary(&plugin, installed_plugin.as_ref()), description: non_empty_string(Some(&plugin.release.description)), @@ -473,7 +523,7 @@ pub async fn install_remote_plugin( let url = format!("{base_url}/ps/plugins/{plugin_id}/install"); let client = build_reqwest_client(); let request = authenticated_request(client.post(&url), auth)?; - let response: RemotePluginInstallResponse = send_and_decode(request, &url).await?; + let response: RemotePluginMutationResponse = send_and_decode(request, &url).await?; if response.id != plugin_id { return Err(RemotePluginCatalogError::UnexpectedPluginId { expected: plugin_id.to_string(), @@ -491,6 +541,121 @@ pub async fn install_remote_plugin( Ok(()) } +pub async fn uninstall_remote_plugin( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, + codex_home: PathBuf, + plugin_id: &str, +) -> Result<(), RemotePluginCatalogError> { + let auth = ensure_chatgpt_auth(auth)?; + + let base_url = config.chatgpt_base_url.trim_end_matches('/'); + let url = format!("{base_url}/plugins/{plugin_id}/uninstall"); + let client = build_reqwest_client(); + let request = authenticated_request(client.post(&url), auth)?; + let response: RemotePluginMutationResponse = send_and_decode(request, &url).await?; + if response.id != plugin_id { + return Err(RemotePluginCatalogError::UnexpectedPluginId { + expected: plugin_id.to_string(), + actual: response.id, + }); + } + if response.enabled { + return Err(RemotePluginCatalogError::UnexpectedEnabledState { + plugin_id: plugin_id.to_string(), + expected_enabled: false, + actual_enabled: response.enabled, + }); + } + + let remote_detail = match fetch_remote_plugin_detail_by_id(config, auth, plugin_id).await { + Ok(remote_detail) => Some(remote_detail), + Err(err) => { + warn!( + plugin_id, + "failed to read remote plugin details after uninstall; skipping named cache removal: {err}" + ); + None + } + }; + let legacy_plugin_id = plugin_id.to_string(); + tokio::task::spawn_blocking(move || { + remove_remote_plugin_cache(codex_home, remote_detail, legacy_plugin_id) + }) + .await + .map_err(|err| { + RemotePluginCatalogError::CacheRemove(format!( + "failed to join remote plugin cache removal task: {err}" + )) + })? + .map_err(RemotePluginCatalogError::CacheRemove)?; + + Ok(()) +} + +fn remove_remote_plugin_cache( + codex_home: PathBuf, + remote_detail: Option, + legacy_plugin_id: String, +) -> Result<(), String> { + if let Some(remote_detail) = remote_detail { + let marketplace_name = remote_detail.marketplace_name; + let plugin_name = remote_detail.summary.name; + let store = PluginStore::try_new(codex_home.clone()) + .map_err(|err| format!("failed to resolve remote plugin cache root: {err}"))?; + let plugin_id = PluginId::new(plugin_name.clone(), marketplace_name.clone()).map_err( + |err| { + format!( + "invalid remote plugin cache id for `{plugin_name}` in `{marketplace_name}`: {err}" + ) + }, + )?; + let plugin_cache_root = store.plugin_base_root(&plugin_id); + store.uninstall(&plugin_id).map_err(|err| { + format!( + "failed to remove remote plugin cache entry {}: {err}", + plugin_cache_root.display() + ) + })?; + + let legacy_remote_plugin_cache_root = codex_home + .join(PLUGINS_CACHE_DIR) + .join(marketplace_name) + .join(legacy_plugin_id); + if legacy_remote_plugin_cache_root != plugin_cache_root.as_path() { + remove_path_if_exists(&legacy_remote_plugin_cache_root)?; + } + return Ok(()); + } + + for scope in RemotePluginScope::all() { + let legacy_remote_plugin_cache_root = codex_home + .join(PLUGINS_CACHE_DIR) + .join(scope.marketplace_name()) + .join(&legacy_plugin_id); + remove_path_if_exists(&legacy_remote_plugin_cache_root)?; + } + Ok(()) +} + +fn remove_path_if_exists(path: &Path) -> Result<(), String> { + if !path.exists() { + return Ok(()); + } + + let result = if path.is_dir() { + fs::remove_dir_all(path) + } else { + fs::remove_file(path) + }; + result.map_err(|err| { + format!( + "failed to remove remote plugin cache entry {}: {err}", + path.display() + ) + }) +} + fn build_remote_plugin_summary( plugin: &RemotePluginDirectoryItem, installed_plugin: Option<&RemotePluginInstalledItem>, From fd36838cf30f837a0ae66f540c49e0f85432905c Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 28 Apr 2026 12:31:45 +0200 Subject: [PATCH 04/53] Add MultiAgentV2 root and subagent context hints (#19805) ## Why MultiAgentV2 sessions need startup guidance that matches the role of the thread that is actually being created. Root agents and subagents have different responsibilities, and forked subagents can inherit parent rollout history. If the parent hint is carried into the child context, the child can see stale or conflicting developer guidance before its own session-specific context is added. ## What changed - Added `features.multi_agent_v2.root_agent_usage_hint_text` and `features.multi_agent_v2.subagent_usage_hint_text` config fields, including schema/config parsing support. - Injected the matching root or subagent hint into the initial context as its own developer message when `multi_agent_v2` is enabled. - Filtered configured MultiAgentV2 usage-hint developer messages out of forked parent history so a child thread receives fresh guidance for its own session source/config. - Added targeted coverage for config parsing, initial-context rendering, feature-config deserialization, and forked-history filtering. ## Context examples With this config: ```toml [features.multi_agent_v2] enabled = true root_agent_usage_hint_text = "Root guidance." subagent_usage_hint_text = "Subagent guidance." ``` A root thread initial context renders the root hint as a standalone developer message: ```text [developer] [developer] Root guidance. ``` A subagent thread initial context renders the subagent hint instead: ```text [developer] [developer] Subagent guidance. ``` When a subagent forks parent history, any parent developer message whose text exactly matches the configured MultiAgentV2 root or subagent hint is omitted from the forked history before the child receives its fresh subagent hint. --- codex-rs/core/config.schema.json | 6 ++ codex-rs/core/src/agent/control.rs | 35 ++++++- codex-rs/core/src/agent/control_tests.rs | 38 ++++++- codex-rs/core/src/config/config_tests.rs | 22 ++++ codex-rs/core/src/config/mod.rs | 16 +++ codex-rs/core/src/session/mod.rs | 30 +++++- codex-rs/core/src/session/multi_agents.rs | 18 ++++ codex-rs/core/src/session/tests.rs | 118 ++++++++++++++++++++++ codex-rs/features/src/feature_configs.rs | 4 + codex-rs/features/src/tests.rs | 6 ++ 10 files changed, 289 insertions(+), 4 deletions(-) create mode 100644 codex-rs/core/src/session/multi_agents.rs diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index bdccafcb53..c4a72c90b0 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1315,6 +1315,12 @@ "minimum": 1.0, "type": "integer" }, + "root_agent_usage_hint_text": { + "type": "string" + }, + "subagent_usage_hint_text": { + "type": "string" + }, "usage_hint_enabled": { "type": "boolean" }, diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 433e3b8c91..187fc29e9d 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -19,6 +19,7 @@ use codex_protocol::AgentPath; use codex_protocol::ThreadId; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; +use codex_protocol::models::ContentItem; use codex_protocol::models::MessagePhase; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::InitialHistory; @@ -395,7 +396,39 @@ impl AgentControl { forked_rollout_items = truncate_rollout_to_last_n_fork_turns(&forked_rollout_items, *last_n_turns); } - forked_rollout_items.retain(keep_forked_rollout_item); + // MultiAgentV2 root/subagent usage hints are injected as standalone developer + // messages at thread start. When forking history, drop hints from the parent + // so the child gets a fresh hint that matches its own session source/config. + let multi_agent_v2_usage_hint_texts_to_filter: Vec = + if let Some(parent_thread) = parent_thread.as_ref() { + parent_thread + .codex + .configured_multi_agent_v2_usage_hint_texts() + .await + } else if config.features.enabled(Feature::MultiAgentV2) { + [ + config.multi_agent_v2.root_agent_usage_hint_text.clone(), + config.multi_agent_v2.subagent_usage_hint_text.clone(), + ] + .into_iter() + .flatten() + .collect() + } else { + Vec::new() + }; + forked_rollout_items.retain(|item| { + if let RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. }) = item + && role == "developer" + && let [ContentItem::InputText { text }] = content.as_slice() + && multi_agent_v2_usage_hint_texts_to_filter + .iter() + .any(|usage_hint_text| usage_hint_text == text) + { + return false; + } + + keep_forked_rollout_item(item) + }); state .fork_thread_with_source( diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index daa86718fa..8ce7fa2ea2 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -595,7 +595,25 @@ async fn spawn_agent_creates_thread_and_sends_prompt() { #[tokio::test] async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { let harness = AgentControlHarness::new().await; - let (parent_thread_id, parent_thread) = harness.start_thread().await; + let mut parent_config = harness.config.clone(); + let _ = parent_config.features.enable(Feature::MultiAgentV2); + parent_config.multi_agent_v2.root_agent_usage_hint_text = + Some("Parent root guidance.".to_string()); + parent_config.multi_agent_v2.subagent_usage_hint_text = + Some("Parent subagent guidance.".to_string()); + let mut child_config = harness.config.clone(); + let _ = child_config.features.enable(Feature::MultiAgentV2); + child_config.multi_agent_v2.root_agent_usage_hint_text = + Some("Child root guidance.".to_string()); + child_config.multi_agent_v2.subagent_usage_hint_text = + Some("Child subagent guidance.".to_string()); + let new_thread = harness + .manager + .start_thread(parent_config) + .await + .expect("start parent thread"); + let parent_thread_id = new_thread.thread_id; + let parent_thread = new_thread.thread; parent_thread .inject_user_message_without_turn("parent seed context".to_string()) .await; @@ -614,6 +632,22 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { .record_conversation_items( turn_context.as_ref(), &[ + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "Parent root guidance.".to_string(), + }], + phase: None, + }, + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "Parent subagent guidance.".to_string(), + }], + phase: None, + }, assistant_message("parent commentary", Some(MessagePhase::Commentary)), assistant_message("parent final answer", Some(MessagePhase::FinalAnswer)), assistant_message("parent unknown phase", /*phase*/ None), @@ -643,7 +677,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { let child_thread_id = harness .control .spawn_agent_with_metadata( - harness.config.clone(), + child_config, text_input("child task"), Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 21a86dcadd..a2b5312103 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -7444,6 +7444,8 @@ enabled = true max_concurrent_threads_per_session = 5 usage_hint_enabled = false usage_hint_text = "Custom delegation guidance." +root_agent_usage_hint_text = "Root guidance." +subagent_usage_hint_text = "Subagent guidance." hide_spawn_agent_metadata = true "#, )?; @@ -7462,6 +7464,14 @@ hide_spawn_agent_metadata = true config.multi_agent_v2.usage_hint_text.as_deref(), Some("Custom delegation guidance.") ); + assert_eq!( + config.multi_agent_v2.root_agent_usage_hint_text.as_deref(), + Some("Root guidance.") + ); + assert_eq!( + config.multi_agent_v2.subagent_usage_hint_text.as_deref(), + Some("Subagent guidance.") + ); assert!(config.multi_agent_v2.hide_spawn_agent_metadata); Ok(()) @@ -7478,12 +7488,16 @@ async fn profile_multi_agent_v2_config_overrides_base() -> std::io::Result<()> { max_concurrent_threads_per_session = 4 usage_hint_enabled = true usage_hint_text = "base hint" +root_agent_usage_hint_text = "base root hint" +subagent_usage_hint_text = "base subagent hint" hide_spawn_agent_metadata = true [profiles.no_hint.features.multi_agent_v2] max_concurrent_threads_per_session = 6 usage_hint_enabled = false usage_hint_text = "profile hint" +root_agent_usage_hint_text = "profile root hint" +subagent_usage_hint_text = "profile subagent hint" hide_spawn_agent_metadata = false "#, )?; @@ -7500,6 +7514,14 @@ hide_spawn_agent_metadata = false config.multi_agent_v2.usage_hint_text.as_deref(), Some("profile hint") ); + assert_eq!( + config.multi_agent_v2.root_agent_usage_hint_text.as_deref(), + Some("profile root hint") + ); + assert_eq!( + config.multi_agent_v2.subagent_usage_hint_text.as_deref(), + Some("profile subagent hint") + ); assert!(!config.multi_agent_v2.hide_spawn_agent_metadata); Ok(()) diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index f4a73dd25f..1150aed7e7 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -731,6 +731,8 @@ pub struct MultiAgentV2Config { pub max_concurrent_threads_per_session: usize, pub usage_hint_enabled: bool, pub usage_hint_text: Option, + pub root_agent_usage_hint_text: Option, + pub subagent_usage_hint_text: Option, pub hide_spawn_agent_metadata: bool, } @@ -741,6 +743,8 @@ impl Default for MultiAgentV2Config { DEFAULT_MULTI_AGENT_V2_MAX_CONCURRENT_THREADS_PER_SESSION, usage_hint_enabled: true, usage_hint_text: None, + root_agent_usage_hint_text: None, + subagent_usage_hint_text: None, hide_spawn_agent_metadata: false, } } @@ -1619,6 +1623,16 @@ fn resolve_multi_agent_v2_config( .or_else(|| base.and_then(|config| config.usage_hint_text.as_ref())) .cloned() .or(default.usage_hint_text); + let root_agent_usage_hint_text = profile + .and_then(|config| config.root_agent_usage_hint_text.as_ref()) + .or_else(|| base.and_then(|config| config.root_agent_usage_hint_text.as_ref())) + .cloned() + .or(default.root_agent_usage_hint_text); + let subagent_usage_hint_text = profile + .and_then(|config| config.subagent_usage_hint_text.as_ref()) + .or_else(|| base.and_then(|config| config.subagent_usage_hint_text.as_ref())) + .cloned() + .or(default.subagent_usage_hint_text); let hide_spawn_agent_metadata = profile .and_then(|config| config.hide_spawn_agent_metadata) .or_else(|| base.and_then(|config| config.hide_spawn_agent_metadata)) @@ -1628,6 +1642,8 @@ fn resolve_multi_agent_v2_config( max_concurrent_threads_per_session, usage_hint_enabled, usage_hint_text, + root_agent_usage_hint_text, + subagent_usage_hint_text, hide_spawn_agent_metadata, } } diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index c01363f55a..cb88f44461 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -184,6 +184,7 @@ use codex_protocol::exec_output::StreamOutput; mod handlers; mod mcp; +mod multi_agents; mod review; mod rollout_reconstruction; #[allow(clippy::module_inception)] @@ -763,6 +764,22 @@ impl Codex { state.session_configuration.thread_config_snapshot() } + pub(crate) async fn configured_multi_agent_v2_usage_hint_texts(&self) -> Vec { + let state = self.session.state.lock().await; + let config = &state.session_configuration.original_config_do_not_use; + if !config.features.enabled(Feature::MultiAgentV2) { + return Vec::new(); + } + + [ + config.multi_agent_v2.root_agent_usage_hint_text.clone(), + config.multi_agent_v2.subagent_usage_hint_text.clone(), + ] + .into_iter() + .flatten() + .collect() + } + pub(crate) fn state_db(&self) -> Option { self.session.state_db() } @@ -2654,12 +2671,23 @@ impl Session { ); } - let mut items = Vec::with_capacity(3); + let multi_agent_v2_usage_hint_text = + multi_agents::usage_hint_text(turn_context, &session_source); + + let mut items = Vec::with_capacity(4); if let Some(developer_message) = crate::context_manager::updates::build_developer_update_item(developer_sections) { items.push(developer_message); } + if let Some(usage_hint_text) = multi_agent_v2_usage_hint_text + && let Some(usage_hint_message) = + crate::context_manager::updates::build_developer_update_item(vec![ + usage_hint_text.to_string(), + ]) + { + items.push(usage_hint_message); + } if let Some(contextual_user_message) = crate::context_manager::updates::build_contextual_user_message(contextual_user_sections) { diff --git a/codex-rs/core/src/session/multi_agents.rs b/codex-rs/core/src/session/multi_agents.rs new file mode 100644 index 0000000000..c94f89602c --- /dev/null +++ b/codex-rs/core/src/session/multi_agents.rs @@ -0,0 +1,18 @@ +use crate::session::turn_context::TurnContext; +use codex_features::Feature; +use codex_protocol::protocol::SessionSource; + +pub(super) fn usage_hint_text<'a>( + turn_context: &'a TurnContext, + session_source: &SessionSource, +) -> Option<&'a str> { + if !turn_context.features.enabled(Feature::MultiAgentV2) { + return None; + } + + let multi_agent_v2 = &turn_context.config.multi_agent_v2; + match session_source { + SessionSource::SubAgent(_) => multi_agent_v2.subagent_usage_hint_text.as_deref(), + _ => multi_agent_v2.root_agent_usage_hint_text.as_deref(), + } +} diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 9079f5990d..b6153e724d 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -372,6 +372,27 @@ fn developer_input_texts(items: &[ResponseItem]) -> Vec<&str> { .collect() } +fn developer_message_texts(items: &[ResponseItem]) -> Vec> { + items + .iter() + .filter_map(|item| match item { + ResponseItem::Message { role, content, .. } if role == "developer" => { + Some(content.as_slice()) + } + _ => None, + }) + .map(|content| { + content + .iter() + .filter_map(|item| match item { + ContentItem::InputText { text } => Some(text.as_str()), + _ => None, + }) + .collect() + }) + .collect() +} + fn user_input_texts(items: &[ResponseItem]) -> Vec<&str> { items .iter() @@ -5313,6 +5334,103 @@ async fn build_initial_context_uses_previous_realtime_state() { ); } +async fn make_multi_agent_v2_usage_hint_test_session( + enable_multi_agent_v2: bool, +) -> (Arc, Arc) { + let (session, turn_context, _rx_event) = make_session_and_context_with_auth_and_config_and_rx( + CodexAuth::from_api_key("Test API Key"), + Vec::new(), + |config| { + if enable_multi_agent_v2 { + let _ = config.features.enable(Feature::MultiAgentV2); + } + config.multi_agent_v2.root_agent_usage_hint_text = Some("Root guidance.".to_string()); + config.multi_agent_v2.subagent_usage_hint_text = Some("Subagent guidance.".to_string()); + }, + ) + .await; + (session, turn_context) +} + +#[tokio::test] +async fn build_initial_context_adds_multi_agent_v2_root_usage_hint_as_developer_message() { + let (session, turn_context) = + make_multi_agent_v2_usage_hint_test_session(/*enable_multi_agent_v2*/ true).await; + + let initial_context = session.build_initial_context(turn_context.as_ref()).await; + + let developer_messages = developer_message_texts(&initial_context); + assert!( + developer_messages + .iter() + .any(|message| message.as_slice() == ["Root guidance."]), + "expected standalone root usage hint developer message, got {developer_messages:?}" + ); + assert!( + !developer_messages + .iter() + .any(|message| message.as_slice() == ["Subagent guidance."]), + "did not expect subagent usage hint for root thread, got {developer_messages:?}" + ); +} + +#[tokio::test] +async fn build_initial_context_adds_multi_agent_v2_subagent_usage_hint_as_developer_message() { + let (session, mut turn_context) = + make_multi_agent_v2_usage_hint_test_session(/*enable_multi_agent_v2*/ true).await; + let session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: ThreadId::new(), + depth: 1, + agent_path: Some(AgentPath::try_from("/root/worker").expect("agent path should parse")), + agent_nickname: Some("worker".to_string()), + agent_role: None, + }); + session + .state + .lock() + .await + .session_configuration + .session_source = session_source.clone(); + Arc::get_mut(&mut turn_context) + .expect("turn context should not be shared") + .session_source = session_source; + + let initial_context = session.build_initial_context(turn_context.as_ref()).await; + + let developer_messages = developer_message_texts(&initial_context); + assert!( + developer_messages + .iter() + .any(|message| message.as_slice() == ["Subagent guidance."]), + "expected standalone subagent usage hint developer message, got {developer_messages:?}" + ); + assert!( + !developer_messages + .iter() + .any(|message| message.as_slice() == ["Root guidance."]), + "did not expect root usage hint for subagent thread, got {developer_messages:?}" + ); +} + +#[tokio::test] +async fn build_initial_context_omits_multi_agent_v2_usage_hints_when_feature_disabled() { + let (session, turn_context) = + make_multi_agent_v2_usage_hint_test_session(/*enable_multi_agent_v2*/ false).await; + + let initial_context = session.build_initial_context(turn_context.as_ref()).await; + + let developer_messages = developer_message_texts(&initial_context); + assert!( + !developer_messages.iter().any(|message| { + matches!( + message.as_slice(), + ["Root guidance."] | ["Subagent guidance."] + ) + }), + "did not expect multi-agent v2 usage hint developer messages, got {developer_messages:?}" + ); +} + #[tokio::test] async fn build_initial_context_omits_default_image_save_location_with_image_history() { let (session, turn_context) = make_session_and_context().await; diff --git a/codex-rs/features/src/feature_configs.rs b/codex-rs/features/src/feature_configs.rs index bead1ce037..3906bc2e83 100644 --- a/codex-rs/features/src/feature_configs.rs +++ b/codex-rs/features/src/feature_configs.rs @@ -16,6 +16,10 @@ pub struct MultiAgentV2ConfigToml { #[serde(skip_serializing_if = "Option::is_none")] pub usage_hint_text: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub root_agent_usage_hint_text: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub subagent_usage_hint_text: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub hide_spawn_agent_metadata: Option, } diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index 6b1d638ffe..4e1f9a0951 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -415,6 +415,8 @@ enabled = true max_concurrent_threads_per_session = 4 usage_hint_enabled = false usage_hint_text = "Custom delegation guidance." +root_agent_usage_hint_text = "Root guidance." +subagent_usage_hint_text = "Subagent guidance." hide_spawn_agent_metadata = true "#, ) @@ -431,6 +433,8 @@ hide_spawn_agent_metadata = true max_concurrent_threads_per_session: Some(4), usage_hint_enabled: Some(false), usage_hint_text: Some("Custom delegation guidance.".to_string()), + root_agent_usage_hint_text: Some("Root guidance.".to_string()), + subagent_usage_hint_text: Some("Subagent guidance.".to_string()), hide_spawn_agent_metadata: Some(true), })) ); @@ -463,6 +467,8 @@ usage_hint_enabled = false max_concurrent_threads_per_session: None, usage_hint_enabled: Some(false), usage_hint_text: None, + root_agent_usage_hint_text: None, + subagent_usage_hint_text: None, hide_spawn_agent_metadata: None, })) ); From 431ebeaef70ebb08bba9221d7e0803c1b80d3a04 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 28 Apr 2026 13:03:28 +0200 Subject: [PATCH 05/53] feat: split memories part 2 (#19860) Keep extracting memories out of core and moving the write trigger in the app-server This is temporary and it should move at the client level as a follow-up This makes core fully independant from `codex-memories-write` --------- Co-authored-by: Codex --- codex-rs/Cargo.lock | 18 +- codex-rs/agent-identity/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 1 + .../typescript/InternalSessionSource.ts | 5 + .../schema/typescript/SessionSource.ts | 3 +- .../schema/typescript/index.ts | 1 + .../app-server-protocol/src/protocol/v2.rs | 2 + codex-rs/app-server/Cargo.toml | 1 + .../app-server/src/codex_message_processor.rs | 44 +- codex-rs/cli/Cargo.toml | 1 + codex-rs/cli/src/main.rs | 2 +- codex-rs/core/Cargo.toml | 2 - codex-rs/core/src/agent/control.rs | 21 +- codex-rs/core/src/client.rs | 30 +- codex-rs/core/src/client_tests.rs | 13 + codex-rs/core/src/codex_thread.rs | 13 +- codex-rs/core/src/config/mod.rs | 2 +- .../src/context/contextual_user_message.rs | 22 - .../context/contextual_user_message_tests.rs | 32 - codex-rs/core/src/context/mod.rs | 1 - codex-rs/core/src/installation_id.rs | 2 +- codex-rs/core/src/lib.rs | 5 +- codex-rs/core/src/memories/mod.rs | 73 - codex-rs/core/src/memories/phase1.rs | 620 -------- codex-rs/core/src/memories/phase1_tests.rs | 152 -- codex-rs/core/src/memories/start.rs | 44 - codex-rs/core/src/memories/tests.rs | 1358 ----------------- codex-rs/core/src/rollout.rs | 4 - codex-rs/core/src/session/handlers.rs | 68 - codex-rs/core/src/session/mod.rs | 16 +- codex-rs/core/src/session/session.rs | 11 +- codex-rs/core/src/thread_manager.rs | 58 +- codex-rs/core/src/thread_manager_tests.rs | 51 +- .../src/tools/handlers/request_user_input.rs | 3 +- .../handlers/request_user_input_tests.rs | 1 + codex-rs/core/tests/suite/mod.rs | 1 - codex-rs/memories/write/Cargo.toml | 16 +- codex-rs/memories/write/src/control.rs | 72 + codex-rs/memories/write/src/lib.rs | 16 +- codex-rs/memories/write/src/phase1.rs | 803 ++++++++++ .../memories => memories/write/src}/phase2.rs | 385 ++--- codex-rs/memories/write/src/runtime.rs | 293 ++++ codex-rs/memories/write/src/start.rs | 55 + .../write/src/startup_tests.rs} | 199 ++- codex-rs/memories/write/src/storage_tests.rs | 88 ++ codex-rs/protocol/src/protocol.rs | 57 +- codex-rs/thread-store/src/remote/helpers.rs | 1 + 47 files changed, 1932 insertions(+), 2735 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/InternalSessionSource.ts delete mode 100644 codex-rs/core/src/memories/mod.rs delete mode 100644 codex-rs/core/src/memories/phase1.rs delete mode 100644 codex-rs/core/src/memories/phase1_tests.rs delete mode 100644 codex-rs/core/src/memories/start.rs delete mode 100644 codex-rs/core/src/memories/tests.rs create mode 100644 codex-rs/memories/write/src/phase1.rs rename codex-rs/{core/src/memories => memories/write/src}/phase2.rs (57%) create mode 100644 codex-rs/memories/write/src/runtime.rs create mode 100644 codex-rs/memories/write/src/start.rs rename codex-rs/{core/tests/suite/memories.rs => memories/write/src/startup_tests.rs} (68%) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index de11d1c158..164e8a0857 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1859,6 +1859,7 @@ dependencies = [ "codex-git-utils", "codex-login", "codex-mcp", + "codex-memories-write", "codex-model-provider", "codex-model-provider-info", "codex-models-manager", @@ -2118,6 +2119,7 @@ dependencies = [ "codex-login", "codex-mcp", "codex-mcp-server", + "codex-memories-write", "codex-models-manager", "codex-protocol", "codex-responses-api-proxy", @@ -2373,7 +2375,6 @@ dependencies = [ "codex-login", "codex-mcp", "codex-memories-read", - "codex-memories-write", "codex-model-provider", "codex-model-provider-info", "codex-models-manager", @@ -2386,7 +2387,6 @@ dependencies = [ "codex-rollout", "codex-rollout-trace", "codex-sandboxing", - "codex-secrets", "codex-shell-command", "codex-shell-escalation", "codex-state", @@ -2957,18 +2957,32 @@ version = "0.0.0" dependencies = [ "anyhow", "chrono", + "codex-config", + "codex-core", + "codex-features", "codex-git-utils", + "codex-login", "codex-models-manager", + "codex-otel", "codex-protocol", + "codex-rollout", + "codex-rollout-trace", + "codex-secrets", "codex-state", + "codex-terminal-detection", "codex-utils-absolute-path", "codex-utils-output-truncation", "codex-utils-template", + "core_test_support", + "futures", "pretty_assertions", + "serde", + "serde_json", "tempfile", "tokio", "tracing", "uuid", + "wiremock", ] [[package]] diff --git a/codex-rs/agent-identity/src/lib.rs b/codex-rs/agent-identity/src/lib.rs index bf139f7870..69801f6fc2 100644 --- a/codex-rs/agent-identity/src/lib.rs +++ b/codex-rs/agent-identity/src/lib.rs @@ -322,6 +322,7 @@ pub fn build_abom(session_source: SessionSource) -> AgentBillOfMaterials { | SessionSource::Exec | SessionSource::Mcp | SessionSource::Custom(_) + | SessionSource::Internal(_) | SessionSource::SubAgent(_) | SessionSource::Unknown => "codex-cli".to_string(), }, diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 0445975dd6..290dbe28d3 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -106,6 +106,7 @@ impl ThreadMetadataState { | SessionSource::Exec | SessionSource::Mcp | SessionSource::Custom(_) + | SessionSource::Internal(_) | SessionSource::Unknown => (None, None), }; Self { diff --git a/codex-rs/app-server-protocol/schema/typescript/InternalSessionSource.ts b/codex-rs/app-server-protocol/schema/typescript/InternalSessionSource.ts new file mode 100644 index 0000000000..47417c5167 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/InternalSessionSource.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type InternalSessionSource = "memory_consolidation"; diff --git a/codex-rs/app-server-protocol/schema/typescript/SessionSource.ts b/codex-rs/app-server-protocol/schema/typescript/SessionSource.ts index a80b013b22..3317c228b0 100644 --- a/codex-rs/app-server-protocol/schema/typescript/SessionSource.ts +++ b/codex-rs/app-server-protocol/schema/typescript/SessionSource.ts @@ -1,6 +1,7 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { InternalSessionSource } from "./InternalSessionSource"; import type { SubAgentSource } from "./SubAgentSource"; -export type SessionSource = "cli" | "vscode" | "exec" | "mcp" | { "custom": string } | { "subagent": SubAgentSource } | "unknown"; +export type SessionSource = "cli" | "vscode" | "exec" | "mcp" | { "custom": string } | { "internal": InternalSessionSource } | { "subagent": SubAgentSource } | "unknown"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index a5d3dbe57d..a082e045fa 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -37,6 +37,7 @@ export type { InitializeCapabilities } from "./InitializeCapabilities"; export type { InitializeParams } from "./InitializeParams"; export type { InitializeResponse } from "./InitializeResponse"; export type { InputModality } from "./InputModality"; +export type { InternalSessionSource } from "./InternalSessionSource"; export type { LocalShellAction } from "./LocalShellAction"; export type { LocalShellExecAction } from "./LocalShellExecAction"; export type { LocalShellStatus } from "./LocalShellStatus"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index d59363df44..64f16d1625 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1996,6 +1996,8 @@ impl From for SessionSource { CoreSessionSource::Exec => SessionSource::Exec, CoreSessionSource::Mcp => SessionSource::AppServer, CoreSessionSource::Custom(source) => SessionSource::Custom(source), + // We do not want to render those at the app-server level. + CoreSessionSource::Internal(_) => SessionSource::Unknown, CoreSessionSource::SubAgent(sub) => SessionSource::SubAgent(sub), CoreSessionSource::Unknown => SessionSource::Unknown, } diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index d78486f38f..d1721c99d1 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -48,6 +48,7 @@ codex-backend-client = { workspace = true } codex-file-search = { workspace = true } codex-chatgpt = { workspace = true } codex-login = { workspace = true } +codex-memories-write = { workspace = true } codex-mcp = { workspace = true } codex-model-provider = { workspace = true } codex-models-manager = { workspace = true } diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index eb67a2051f..519ea50fd1 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -241,11 +241,10 @@ use codex_core::ForkSnapshot; use codex_core::NewThread; use codex_core::RolloutRecorder; use codex_core::SessionMeta; -use codex_core::StartThreadWithToolsOptions; +use codex_core::StartThreadOptions; use codex_core::SteerInputError; use codex_core::ThreadConfigSnapshot; use codex_core::ThreadManager; -use codex_core::clear_memory_roots_contents; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::NetworkProxyAuditMetadata; @@ -315,6 +314,7 @@ use codex_mcp::discover_supported_scopes; use codex_mcp::effective_mcp_servers; use codex_mcp::read_mcp_resource as read_mcp_resource_without_thread; use codex_mcp::resolve_oauth_scopes; +use codex_memories_write::clear_memory_roots_contents; use codex_model_provider::ProviderAccountError; use codex_model_provider::create_model_provider; use codex_models_manager::collaboration_mode_presets::CollaborationModesConfig; @@ -542,6 +542,7 @@ pub(crate) enum ApiVersion { #[derive(Clone)] struct ListenerTaskContext { + auth_manager: Arc, thread_manager: Arc, thread_state_manager: ThreadStateManager, outgoing: Arc, @@ -2414,6 +2415,7 @@ impl CodexMessageProcessor { ); typesafe_overrides.ephemeral = ephemeral; let listener_task_context = ListenerTaskContext { + auth_manager: Arc::clone(&self.auth_manager), thread_manager: Arc::clone(&self.thread_manager), thread_state_manager: self.thread_state_manager.clone(), outgoing: Arc::clone(&self.outgoing), @@ -2613,6 +2615,7 @@ impl CodexMessageProcessor { .collect() }; let core_dynamic_tool_count = core_dynamic_tools.len(); + let memory_config = Arc::new(config.clone()); let NewThread { thread_id, @@ -2621,7 +2624,7 @@ impl CodexMessageProcessor { .. } = listener_task_context .thread_manager - .start_thread_with_tools_and_service_name(StartThreadWithToolsOptions { + .start_thread_with_options(StartThreadOptions { config, initial_history: match session_start_source .unwrap_or(codex_app_server_protocol::ThreadStartSource::Startup) @@ -2633,6 +2636,7 @@ impl CodexMessageProcessor { InitialHistory::Cleared } }, + session_source: None, dynamic_tools: core_dynamic_tools, persist_extended_history, metrics_service_name: service_name, @@ -2665,6 +2669,14 @@ impl CodexMessageProcessor { otel.name = "app_server.thread_start.config_snapshot", )) .await; + codex_memories_write::start_memories_startup_task( + Arc::clone(&listener_task_context.thread_manager), + Arc::clone(&listener_task_context.auth_manager), + thread_id, + Arc::clone(&thread), + Arc::clone(&memory_config), + &config_snapshot.session_source, + ); let mut thread = build_thread_from_snapshot( thread_id, &config_snapshot, @@ -3659,13 +3671,13 @@ impl CodexMessageProcessor { params: ThreadLoadedListParams, ) -> Result { let ThreadLoadedListParams { cursor, limit } = params; - let mut data = self + let mut data: Vec = self .thread_manager .list_thread_ids() .await .into_iter() .map(|thread_id| thread_id.to_string()) - .collect::>(); + .collect(); if data.is_empty() { return Ok(ThreadLoadedListResponse { @@ -4166,6 +4178,7 @@ impl CodexMessageProcessor { let instruction_sources = Self::instruction_sources_from_config(&config).await; let response_history = thread_history.clone(); + let memory_config = Arc::new(config.clone()); match self .thread_manager @@ -4240,6 +4253,14 @@ impl CodexMessageProcessor { /*has_live_in_progress_turn*/ false, ); let config_snapshot = codex_thread.config_snapshot().await; + codex_memories_write::start_memories_startup_task( + Arc::clone(&self.thread_manager), + Arc::clone(&self.auth_manager), + thread_id, + Arc::clone(&codex_thread), + Arc::clone(&memory_config), + &config_snapshot.session_source, + ); let sandbox = thread_response_sandbox_policy( &config_snapshot.permission_profile, config_snapshot.cwd.as_path(), @@ -4745,6 +4766,7 @@ impl CodexMessageProcessor { let fallback_model_provider = config.model_provider_id.clone(); let instruction_sources = Self::instruction_sources_from_config(&config).await; let fork_thread_store = configured_thread_store(&config); + let memory_config = Arc::new(config.clone()); let NewThread { thread_id, @@ -4840,6 +4862,14 @@ impl CodexMessageProcessor { /*has_in_progress_turn*/ false, ); let config_snapshot = forked_thread.config_snapshot().await; + codex_memories_write::start_memories_startup_task( + Arc::clone(&self.thread_manager), + Arc::clone(&self.auth_manager), + thread_id, + Arc::clone(&forked_thread), + Arc::clone(&memory_config), + &config_snapshot.session_source, + ); let sandbox = thread_response_sandbox_policy( &config_snapshot.permission_profile, config_snapshot.cwd.as_path(), @@ -4878,7 +4908,6 @@ impl CodexMessageProcessor { return; } }; - self.analytics_events_client.track_response( request_id.connection_id.0, ClientResponse::ThreadFork { @@ -7159,6 +7188,7 @@ impl CodexMessageProcessor { ) -> Result { Self::ensure_conversation_listener_task( ListenerTaskContext { + auth_manager: Arc::clone(&self.auth_manager), thread_manager: Arc::clone(&self.thread_manager), thread_state_manager: self.thread_state_manager.clone(), outgoing: Arc::clone(&self.outgoing), @@ -7276,6 +7306,7 @@ impl CodexMessageProcessor { ) -> Result<(), JSONRPCErrorError> { Self::ensure_listener_task_running_task( ListenerTaskContext { + auth_manager: Arc::clone(&self.auth_manager), thread_manager: Arc::clone(&self.thread_manager), thread_state_manager: self.thread_state_manager.clone(), outgoing: Arc::clone(&self.outgoing), @@ -7324,6 +7355,7 @@ impl CodexMessageProcessor { thread_state.set_listener(cancel_tx, &conversation) }; let ListenerTaskContext { + auth_manager: _, outgoing, thread_manager, thread_state_manager, diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 5f4f3aee10..cdee241b42 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -35,6 +35,7 @@ codex-exec-server = { workspace = true } codex-execpolicy = { workspace = true } codex-features = { workspace = true } codex-login = { workspace = true } +codex-memories-write = { workspace = true } codex-mcp = { workspace = true } codex-mcp-server = { workspace = true } codex-models-manager = { workspace = true } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index c134bc8a29..1c2eabf986 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -52,7 +52,6 @@ use crate::marketplace_cmd::MarketplaceCli; use crate::mcp_cmd::McpCli; use codex_core::build_models_manager; -use codex_core::clear_memory_roots_contents; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::edit::ConfigEditsBuilder; @@ -61,6 +60,7 @@ use codex_features::FEATURES; use codex_features::Stage; use codex_features::is_known_feature_key; use codex_login::AuthManager; +use codex_memories_write::clear_memory_roots_contents; use codex_models_manager::bundled_models_response; use codex_models_manager::collaboration_mode_presets::CollaborationModesConfig; use codex_models_manager::manager::RefreshStrategy; diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index c95b57b718..44c6aacac5 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -40,7 +40,6 @@ codex-features = { workspace = true } codex-feedback = { workspace = true } codex-login = { workspace = true } codex-memories-read = { workspace = true } -codex-memories-write = { workspace = true } codex-mcp = { workspace = true } codex-model-provider-info = { workspace = true } codex-models-manager = { workspace = true } @@ -71,7 +70,6 @@ codex-utils-path = { workspace = true } codex-utils-plugins = { workspace = true } codex-utils-pty = { workspace = true } codex-utils-readiness = { workspace = true } -codex-secrets = { workspace = true } codex-utils-string = { workspace = true } codex-utils-stream-parser = { workspace = true } codex-utils-template = { workspace = true } diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 187fc29e9d..25fc92206e 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -28,7 +28,6 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; -use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TurnEnvironmentSelection; use codex_protocol::user_input::UserInput; use codex_rollout::state_db; @@ -149,16 +148,8 @@ impl AgentControl { } } - /// Create a control-plane handle over the same thread manager with an independent live-agent - /// registry. - pub(crate) fn detached_registry(&self) -> Self { - Self { - manager: self.manager.clone(), - ..Default::default() - } - } - /// Spawn a new agent thread and submit the initial prompt. + #[cfg(test)] pub(crate) async fn spawn_agent( &self, config: crate::config::Config, @@ -830,16 +821,6 @@ impl AgentControl { Ok(thread.subscribe_status()) } - pub(crate) async fn get_total_token_usage(&self, agent_id: ThreadId) -> Option { - let Ok(state) = self.upgrade() else { - return None; - }; - let Ok(thread) = state.get_thread(agent_id).await else { - return None; - }; - thread.total_token_usage().await - } - pub(crate) async fn format_environment_context_subagents( &self, parent_thread_id: ThreadId, diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 3997018eaa..fe64099cc9 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -77,6 +77,7 @@ use codex_protocol::config_types::Verbosity as VerbosityConfig; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; +use codex_protocol::protocol::InternalSessionSource; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::W3cTraceContext; @@ -566,7 +567,7 @@ impl ModelClient { } if matches!( self.state.session_source, - SessionSource::SubAgent(SubAgentSource::MemoryConsolidation) + SessionSource::Internal(InternalSessionSource::MemoryConsolidation) ) { extra_headers.insert( X_OPENAI_MEMGEN_REQUEST_HEADER, @@ -1596,15 +1597,23 @@ fn build_responses_headers( } fn subagent_header_value(session_source: &SessionSource) -> Option { - let SessionSource::SubAgent(subagent_source) = session_source else { - return None; - }; - match subagent_source { - SubAgentSource::Review => Some("review".to_string()), - SubAgentSource::Compact => Some("compact".to_string()), - SubAgentSource::MemoryConsolidation => Some("memory_consolidation".to_string()), - SubAgentSource::ThreadSpawn { .. } => Some("collab_spawn".to_string()), - SubAgentSource::Other(label) => Some(label.clone()), + match session_source { + SessionSource::SubAgent(subagent_source) => match subagent_source { + SubAgentSource::Review => Some("review".to_string()), + SubAgentSource::Compact => Some("compact".to_string()), + SubAgentSource::MemoryConsolidation => Some("memory_consolidation".to_string()), + SubAgentSource::ThreadSpawn { .. } => Some("collab_spawn".to_string()), + SubAgentSource::Other(label) => Some(label.clone()), + }, + SessionSource::Internal(InternalSessionSource::MemoryConsolidation) => { + Some("memory_consolidation".to_string()) + } + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, } } @@ -1618,6 +1627,7 @@ fn parent_thread_id_header_value(session_source: &SessionSource) -> Option None, } diff --git a/codex-rs/core/src/client_tests.rs b/codex-rs/core/src/client_tests.rs index e2934dc1cc..f7d263c0e7 100644 --- a/codex-rs/core/src/client_tests.rs +++ b/codex-rs/core/src/client_tests.rs @@ -18,6 +18,7 @@ use codex_protocol::ThreadId; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelInfo; +use codex_protocol::protocol::InternalSessionSource; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_rollout_trace::ExecutionStatus; @@ -197,6 +198,18 @@ fn build_subagent_headers_sets_other_subagent_label() { assert_eq!(value, Some("memory_consolidation")); } +#[test] +fn build_subagent_headers_sets_internal_memory_consolidation_label() { + let client = test_model_client(SessionSource::Internal( + InternalSessionSource::MemoryConsolidation, + )); + let headers = client.build_subagent_headers(); + let value = headers + .get(X_OPENAI_SUBAGENT_HEADER) + .and_then(|value| value.to_str().ok()); + assert_eq!(value, Some("memory_consolidation")); +} + #[test] fn build_ws_client_metadata_includes_window_lineage_and_turn_metadata() { let parent_thread_id = ThreadId::new(); diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index fa4bd8067c..eb5afe57f6 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -27,7 +27,6 @@ use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::Submission; use codex_protocol::protocol::ThreadMemoryMode; -use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; use codex_protocol::protocol::W3cTraceContext; use codex_protocol::user_input::UserInput; @@ -86,6 +85,7 @@ pub struct CodexThreadTurnContextOverrides { pub struct CodexThread { pub(crate) codex: Codex, + pub(crate) session_source: SessionSource, rollout_path: Option, out_of_band_elicitation_count: Mutex, _watch_registration: WatchRegistration, @@ -97,10 +97,12 @@ impl CodexThread { pub(crate) fn new( codex: Codex, rollout_path: Option, + session_source: SessionSource, watch_registration: WatchRegistration, ) -> Self { Self { codex, + session_source, rollout_path, out_of_band_elicitation_count: Mutex::new(0), _watch_registration: watch_registration, @@ -115,6 +117,11 @@ impl CodexThread { self.codex.shutdown_and_wait().await } + /// Wait until the underlying session loop has terminated. + pub async fn wait_until_terminated(&self) { + self.codex.session_loop_termination.clone().await; + } + pub async fn apply_goal_resume_runtime_effects(&self) -> anyhow::Result<()> { self.codex .session @@ -268,10 +275,6 @@ impl CodexThread { self.codex.agent_status.clone() } - pub(crate) async fn total_token_usage(&self) -> Option { - self.codex.session.total_token_usage().await - } - /// Returns the complete token usage snapshot currently cached for this thread. /// /// This accessor is intentionally narrower than direct session access: it lets diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 1150aed7e7..f8a079b1f2 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -62,7 +62,7 @@ use codex_features::MultiAgentV2ConfigToml; use codex_git_utils::resolve_root_git_project_for_trust; use codex_login::AuthManagerConfig; use codex_mcp::McpConfig; -use codex_memories_write::memory_root; +use codex_memories_read::memory_root; use codex_model_provider_info::LEGACY_OLLAMA_CHAT_PROVIDER_ID; use codex_model_provider_info::ModelProviderInfo; use codex_model_provider_info::OLLAMA_CHAT_PROVIDER_REMOVED_ERROR; diff --git a/codex-rs/core/src/context/contextual_user_message.rs b/codex-rs/core/src/context/contextual_user_message.rs index aeb54d61d7..cd7788afee 100644 --- a/codex-rs/core/src/context/contextual_user_message.rs +++ b/codex-rs/core/src/context/contextual_user_message.rs @@ -33,34 +33,12 @@ static CONTEXTUAL_USER_FRAGMENTS: &[&dyn FragmentRegistration] = &[ &SUBAGENT_NOTIFICATION_REGISTRATION, ]; -static MEMORY_EXCLUDED_CONTEXTUAL_USER_FRAGMENTS: &[&dyn FragmentRegistration] = &[ - &USER_INSTRUCTIONS_REGISTRATION, - &SKILL_INSTRUCTIONS_REGISTRATION, -]; - fn is_standard_contextual_user_text(text: &str) -> bool { CONTEXTUAL_USER_FRAGMENTS .iter() .any(|fragment| fragment.matches_text(text)) } -/// Returns whether a contextual user fragment should be omitted from memory -/// stage-1 inputs. -/// -/// We exclude injected `AGENTS.md` instructions and skill payloads because -/// they are prompt scaffolding rather than conversation content, so they do -/// not improve the resulting memory. We keep environment context and -/// subagent notifications because they can carry useful execution context or -/// subtask outcomes that should remain visible to memory generation. -pub(crate) fn is_memory_excluded_contextual_user_fragment(content_item: &ContentItem) -> bool { - let ContentItem::InputText { text } = content_item else { - return false; - }; - MEMORY_EXCLUDED_CONTEXTUAL_USER_FRAGMENTS - .iter() - .any(|fragment| fragment.matches_text(text)) -} - pub(crate) fn is_contextual_user_fragment(content_item: &ContentItem) -> bool { let ContentItem::InputText { text } = content_item else { return false; diff --git a/codex-rs/core/src/context/contextual_user_message_tests.rs b/codex-rs/core/src/context/contextual_user_message_tests.rs index d52cf27adb..a90b8f280a 100644 --- a/codex-rs/core/src/context/contextual_user_message_tests.rs +++ b/codex-rs/core/src/context/contextual_user_message_tests.rs @@ -33,38 +33,6 @@ fn ignores_regular_user_text() { })); } -#[test] -fn classifies_memory_excluded_fragments() { - let cases = [ - ( - "# AGENTS.md instructions for /tmp\n\n\nbody\n", - true, - ), - ( - "\ndemo\nskills/demo/SKILL.md\nbody\n", - true, - ), - ( - "\n/tmp\n", - false, - ), - ( - "{\"agent_id\":\"a\",\"status\":\"completed\"}", - false, - ), - ]; - - for (text, expected) in cases { - assert_eq!( - is_memory_excluded_contextual_user_fragment(&ContentItem::InputText { - text: text.to_string(), - }), - expected, - "{text}", - ); - } -} - #[test] fn detects_hook_prompt_fragment_and_roundtrips_escaping() { let message = build_hook_prompt_message(&[HookPromptFragment::from_single_hook( diff --git a/codex-rs/core/src/context/mod.rs b/codex-rs/core/src/context/mod.rs index 848a4f7f0c..25a6e1f134 100644 --- a/codex-rs/core/src/context/mod.rs +++ b/codex-rs/core/src/context/mod.rs @@ -31,7 +31,6 @@ pub(crate) use available_plugins_instructions::AvailablePluginsInstructions; pub(crate) use available_skills_instructions::AvailableSkillsInstructions; pub(crate) use collaboration_mode_instructions::CollaborationModeInstructions; pub(crate) use contextual_user_message::is_contextual_user_fragment; -pub(crate) use contextual_user_message::is_memory_excluded_contextual_user_fragment; pub(crate) use contextual_user_message::parse_visible_hook_prompt_message; pub(crate) use environment_context::EnvironmentContext; pub use fragment::ContextualUserFragment; diff --git a/codex-rs/core/src/installation_id.rs b/codex-rs/core/src/installation_id.rs index e9e5445c8c..a42e6b6d83 100644 --- a/codex-rs/core/src/installation_id.rs +++ b/codex-rs/core/src/installation_id.rs @@ -16,7 +16,7 @@ use uuid::Uuid; pub(crate) const INSTALLATION_ID_FILENAME: &str = "installation_id"; -pub(crate) async fn resolve_installation_id(codex_home: &AbsolutePathBuf) -> Result { +pub async fn resolve_installation_id(codex_home: &AbsolutePathBuf) -> Result { let path = codex_home.join(INSTALLATION_ID_FILENAME); fs::create_dir_all(codex_home).await?; tokio::task::spawn_blocking(move || { diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index c5e0da9b8f..05e34efc8c 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -56,8 +56,6 @@ mod original_image_detail; pub use codex_mcp::SandboxState; mod mcp_openai_file; mod mcp_tool_call; -mod memories; -pub use codex_memories_write::clear_memory_roots_contents; pub(crate) mod mention_syntax; pub(crate) mod message_history; pub(crate) mod utils; @@ -119,7 +117,7 @@ pub(crate) mod web_search; pub(crate) mod windows_sandbox_read_grants; pub use thread_manager::ForkSnapshot; pub use thread_manager::NewThread; -pub use thread_manager::StartThreadWithToolsOptions; +pub use thread_manager::StartThreadOptions; pub use thread_manager::ThreadManager; pub use thread_manager::build_models_manager; pub use web_search::web_search_action_detail; @@ -195,6 +193,7 @@ pub use exec_policy::check_execpolicy_for_warnings; pub use exec_policy::format_exec_policy_error_with_source; pub use exec_policy::load_exec_policy; pub use file_watcher::FileWatcherEvent; +pub use installation_id::resolve_installation_id; pub use turn_metadata::build_turn_metadata_header; pub mod compact; pub(crate) mod memory_trace; diff --git a/codex-rs/core/src/memories/mod.rs b/codex-rs/core/src/memories/mod.rs deleted file mode 100644 index efa70f4bbb..0000000000 --- a/codex-rs/core/src/memories/mod.rs +++ /dev/null @@ -1,73 +0,0 @@ -//! Memory startup extraction and consolidation orchestration. -//! -//! The startup memory pipeline is split into two phases: -//! - Phase 1: select rollouts, extract stage-1 raw memories, persist stage-1 outputs, and enqueue consolidation. -//! - Phase 2: claim a global consolidation lock, materialize consolidation inputs, and dispatch one consolidation agent. - -mod phase1; -mod phase2; -mod start; -#[cfg(test)] -mod tests; - -use codex_protocol::openai_models::ReasoningEffort; - -/// Starts the memory startup pipeline for eligible root sessions. -/// This is the single entrypoint that `codex` uses to trigger memory startup. -/// -/// This is the entry point to read and understand this module. -pub(crate) use start::start_memories_startup_task; - -/// Phase 1 (startup extraction). -mod phase_one { - /// Default model used for phase 1. - pub(super) const MODEL: &str = "gpt-5.4-mini"; - /// Default reasoning effort used for phase 1. - pub(super) const REASONING_EFFORT: super::ReasoningEffort = super::ReasoningEffort::Low; - /// Prompt used for phase 1. - pub(super) const PROMPT: &str = codex_memories_write::STAGE_ONE_PROMPT; - /// Concurrency cap for startup memory extraction and consolidation scheduling. - pub(super) const CONCURRENCY_LIMIT: usize = 8; - /// Lease duration (seconds) for phase-1 job ownership. - pub(super) const JOB_LEASE_SECONDS: i64 = 3_600; - /// Backoff delay (seconds) before retrying a failed stage-1 extraction job. - pub(super) const JOB_RETRY_DELAY_SECONDS: i64 = 3_600; - /// Maximum number of threads to scan. - pub(super) const THREAD_SCAN_LIMIT: usize = 5_000; - /// Size of the batches when pruning old thread memories. - pub(super) const PRUNE_BATCH_SIZE: usize = 200; -} - -/// Phase 2 (aka `Consolidation`). -mod phase_two { - /// Default model used for phase 2. - pub(super) const MODEL: &str = "gpt-5.4"; - /// Default reasoning effort used for phase 2. - pub(super) const REASONING_EFFORT: super::ReasoningEffort = super::ReasoningEffort::Medium; - /// Lease duration (seconds) for phase-2 consolidation job ownership. - pub(super) const JOB_LEASE_SECONDS: i64 = 3_600; - /// Backoff delay (seconds) before retrying a failed phase-2 consolidation - /// job. - pub(super) const JOB_RETRY_DELAY_SECONDS: i64 = 3_600; - /// Heartbeat interval (seconds) for phase-2 running jobs. - pub(super) const JOB_HEARTBEAT_SECONDS: u64 = 90; -} - -mod metrics { - /// Number of phase-1 startup jobs grouped by status. - pub(super) const MEMORY_PHASE_ONE_JOBS: &str = "codex.memory.phase1"; - /// End-to-end latency for a single phase-1 startup run. - pub(super) const MEMORY_PHASE_ONE_E2E_MS: &str = "codex.memory.phase1.e2e_ms"; - /// Number of raw memories produced by phase-1 startup extraction. - pub(super) const MEMORY_PHASE_ONE_OUTPUT: &str = "codex.memory.phase1.output"; - /// Histogram for aggregate token usage across one phase-1 startup run. - pub(super) const MEMORY_PHASE_ONE_TOKEN_USAGE: &str = "codex.memory.phase1.token_usage"; - /// Number of phase-2 startup jobs grouped by status. - pub(super) const MEMORY_PHASE_TWO_JOBS: &str = "codex.memory.phase2"; - /// End-to-end latency for a single phase-2 consolidation run. - pub(super) const MEMORY_PHASE_TWO_E2E_MS: &str = "codex.memory.phase2.e2e_ms"; - /// Number of stage-1 memories included in each phase-2 consolidation step. - pub(super) const MEMORY_PHASE_TWO_INPUT: &str = "codex.memory.phase2.input"; - /// Histogram for aggregate token usage across one phase-2 consolidation run. - pub(super) const MEMORY_PHASE_TWO_TOKEN_USAGE: &str = "codex.memory.phase2.token_usage"; -} diff --git a/codex-rs/core/src/memories/phase1.rs b/codex-rs/core/src/memories/phase1.rs deleted file mode 100644 index 40f65c6db0..0000000000 --- a/codex-rs/core/src/memories/phase1.rs +++ /dev/null @@ -1,620 +0,0 @@ -use crate::Prompt; -use crate::RolloutRecorder; -use crate::config::Config; -use crate::context::is_memory_excluded_contextual_user_fragment; -use crate::memories::metrics; -use crate::memories::phase_one; -use crate::memories::phase_one::PRUNE_BATCH_SIZE; -use crate::rollout::INTERACTIVE_SESSION_SOURCES; -use crate::rollout::policy::should_persist_response_item_for_memories; -use crate::session::session::Session; -use crate::session::turn_context::TurnContext; -use codex_api::ResponseEvent; -use codex_config::types::MemoriesConfig; -use codex_memories_write::build_stage_one_input_message; -use codex_otel::SessionTelemetry; -use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; -use codex_protocol::config_types::ServiceTier; -use codex_protocol::error::CodexErr; -use codex_protocol::models::BaseInstructions; -use codex_protocol::models::ContentItem; -use codex_protocol::models::ResponseItem; -use codex_protocol::openai_models::ModelInfo; -use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; -use codex_protocol::protocol::RolloutItem; -use codex_protocol::protocol::TokenUsage; -use codex_rollout_trace::InferenceTraceContext; -use codex_secrets::redact_secrets; -use futures::StreamExt; -use serde::Deserialize; -use serde_json::Value; -use serde_json::json; -use std::path::Path; -use std::sync::Arc; -use tracing::info; -use tracing::warn; - -#[derive(Clone, Debug)] -pub(in crate::memories) struct RequestContext { - pub(in crate::memories) model_info: ModelInfo, - pub(in crate::memories) session_telemetry: SessionTelemetry, - pub(in crate::memories) reasoning_effort: Option, - pub(in crate::memories) reasoning_summary: ReasoningSummaryConfig, - pub(in crate::memories) service_tier: Option, - pub(in crate::memories) turn_metadata_header: Option, -} - -struct JobResult { - outcome: JobOutcome, - token_usage: Option, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum JobOutcome { - SucceededWithOutput, - SucceededNoOutput, - Failed, -} - -struct Stats { - claimed: usize, - succeeded_with_output: usize, - succeeded_no_output: usize, - failed: usize, - total_token_usage: Option, -} - -/// Phase 1 model output payload. -#[derive(Debug, Clone, Deserialize)] -#[serde(deny_unknown_fields)] -struct StageOneOutput { - /// Detailed markdown raw memory for a single rollout. - #[serde(rename = "raw_memory")] - pub(crate) raw_memory: String, - /// Compact summary line used for routing and indexing. - #[serde(rename = "rollout_summary")] - pub(crate) rollout_summary: String, - /// Optional slug used to derive rollout summary artifact filenames. - #[serde(default, rename = "rollout_slug")] - pub(crate) rollout_slug: Option, -} - -/// Runs memory phase 1 in strict step order: -/// 1) claim eligible rollout jobs -/// 2) build one stage-1 request context -/// 3) run stage-1 extraction jobs in parallel -/// 4) emit metrics and logs -pub(in crate::memories) async fn run(session: &Arc, config: &Config) { - let _phase_one_e2e_timer = session - .services - .session_telemetry - .start_timer(metrics::MEMORY_PHASE_ONE_E2E_MS, &[]) - .ok(); - - // 1. Claim startup job. - let Some(claimed_candidates) = claim_startup_jobs(session, &config.memories).await else { - return; - }; - if claimed_candidates.is_empty() { - session.services.session_telemetry.counter( - metrics::MEMORY_PHASE_ONE_JOBS, - /*inc*/ 1, - &[("status", "skipped_no_candidates")], - ); - return; - } - - // 2. Build request. - let stage_one_context = build_request_context(session, config).await; - - // 3. Run the parallel sampling. - let outcomes = run_jobs(session, claimed_candidates, stage_one_context).await; - - // 4. Metrics and logs. - let counts = aggregate_stats(outcomes); - emit_metrics(session, &counts); - info!( - "memory stage-1 extraction complete: {} job(s) claimed, {} succeeded ({} with output, {} no output), {} failed", - counts.claimed, - counts.succeeded_with_output + counts.succeeded_no_output, - counts.succeeded_with_output, - counts.succeeded_no_output, - counts.failed - ); -} - -/// Prune old un-used "dead" raw memories. -pub(in crate::memories) async fn prune(session: &Arc, config: &Config) { - if let Some(db) = session.services.state_db.as_deref() { - let max_unused_days = config.memories.max_unused_days; - match db - .prune_stage1_outputs_for_retention(max_unused_days, PRUNE_BATCH_SIZE) - .await - { - Ok(pruned) => { - if pruned > 0 { - info!( - "memory startup pruned {pruned} stale stage-1 output row(s) older than {max_unused_days} days" - ); - } - } - Err(err) => { - warn!( - "state db prune_stage1_outputs_for_retention failed during memories startup: {err}" - ); - } - } - } -} - -/// JSON schema used to constrain phase-1 model output. -pub fn output_schema() -> Value { - json!({ - "type": "object", - "properties": { - "rollout_summary": { "type": "string" }, - "rollout_slug": { "type": ["string", "null"] }, - "raw_memory": { "type": "string" } - }, - "required": ["rollout_summary", "rollout_slug", "raw_memory"], - "additionalProperties": false - }) -} - -impl RequestContext { - pub(in crate::memories) fn from_turn_context( - turn_context: &TurnContext, - turn_metadata_header: Option, - model_info: ModelInfo, - ) -> Self { - Self { - model_info, - turn_metadata_header, - session_telemetry: turn_context.session_telemetry.clone(), - reasoning_effort: Some(phase_one::REASONING_EFFORT), - reasoning_summary: turn_context.reasoning_summary, - service_tier: turn_context.config.service_tier, - } - } -} - -async fn claim_startup_jobs( - session: &Arc, - memories_config: &MemoriesConfig, -) -> Option> { - let Some(state_db) = session.services.state_db.as_deref() else { - // This should not happen. - warn!("state db unavailable while claiming phase-1 startup jobs; skipping"); - return None; - }; - - let allowed_sources = INTERACTIVE_SESSION_SOURCES - .iter() - .map(ToString::to_string) - .collect::>(); - - match state_db - .claim_stage1_jobs_for_startup( - session.conversation_id, - codex_state::Stage1StartupClaimParams { - scan_limit: phase_one::THREAD_SCAN_LIMIT, - max_claimed: memories_config.max_rollouts_per_startup, - max_age_days: memories_config.max_rollout_age_days, - min_rollout_idle_hours: memories_config.min_rollout_idle_hours, - allowed_sources: allowed_sources.as_slice(), - lease_seconds: phase_one::JOB_LEASE_SECONDS, - }, - ) - .await - { - Ok(claims) => Some(claims), - Err(err) => { - warn!("state db claim_stage1_jobs_for_startup failed during memories startup: {err}"); - session.services.session_telemetry.counter( - metrics::MEMORY_PHASE_ONE_JOBS, - /*inc*/ 1, - &[("status", "failed_claim")], - ); - None - } - } -} - -async fn build_request_context(session: &Arc, config: &Config) -> RequestContext { - let model_name = config - .memories - .extract_model - .clone() - .unwrap_or(phase_one::MODEL.to_string()); - let model = session - .services - .models_manager - .get_model_info(&model_name, &config.to_models_manager_config()) - .await; - let turn_context = session.new_default_turn().await; - RequestContext::from_turn_context( - turn_context.as_ref(), - turn_context.turn_metadata_state.current_header_value(), - model, - ) -} - -async fn run_jobs( - session: &Arc, - claimed_candidates: Vec, - stage_one_context: RequestContext, -) -> Vec { - futures::stream::iter(claimed_candidates.into_iter()) - .map(|claim| { - let session = Arc::clone(session); - let stage_one_context = stage_one_context.clone(); - async move { job::run(session.as_ref(), claim, &stage_one_context).await } - }) - .buffer_unordered(phase_one::CONCURRENCY_LIMIT) - .collect::>() - .await -} - -mod job { - use super::*; - - pub(in crate::memories) async fn run( - session: &Session, - claim: codex_state::Stage1JobClaim, - stage_one_context: &RequestContext, - ) -> JobResult { - let thread = claim.thread; - let (stage_one_output, token_usage) = match sample( - session, - &thread.rollout_path, - &thread.cwd, - stage_one_context, - ) - .await - { - Ok(output) => output, - Err(reason) => { - result::failed( - session, - thread.id, - &claim.ownership_token, - &reason.to_string(), - ) - .await; - return JobResult { - outcome: JobOutcome::Failed, - token_usage: None, - }; - } - }; - - if stage_one_output.raw_memory.is_empty() || stage_one_output.rollout_summary.is_empty() { - return JobResult { - outcome: result::no_output(session, thread.id, &claim.ownership_token).await, - token_usage, - }; - } - - JobResult { - outcome: result::success( - session, - thread.id, - &claim.ownership_token, - thread.updated_at.timestamp(), - &stage_one_output.raw_memory, - &stage_one_output.rollout_summary, - stage_one_output.rollout_slug.as_deref(), - ) - .await, - token_usage, - } - } - - /// Extract the rollout and perform the actual sampling. - async fn sample( - session: &Session, - rollout_path: &Path, - rollout_cwd: &Path, - stage_one_context: &RequestContext, - ) -> anyhow::Result<(StageOneOutput, Option)> { - let (rollout_items, _, _) = RolloutRecorder::load_rollout_items(rollout_path).await?; - let rollout_contents = serialize_filtered_rollout_response_items(&rollout_items)?; - - let prompt = Prompt { - input: vec![ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: build_stage_one_input_message( - &stage_one_context.model_info, - rollout_path, - rollout_cwd, - &rollout_contents, - )?, - }], - phase: None, - }], - tools: Vec::new(), - parallel_tool_calls: false, - base_instructions: BaseInstructions { - text: phase_one::PROMPT.to_string(), - }, - personality: None, - output_schema: Some(output_schema()), - output_schema_strict: true, - }; - - let mut client_session = session.services.model_client.new_session(); - let mut stream = client_session - .stream( - &prompt, - &stage_one_context.model_info, - &stage_one_context.session_telemetry, - stage_one_context.reasoning_effort, - stage_one_context.reasoning_summary, - stage_one_context.service_tier, - stage_one_context.turn_metadata_header.as_deref(), - &InferenceTraceContext::disabled(), - ) - .await?; - - // TODO(jif) we should have a shared helper somewhere for this. - // Unwrap the stream. - let mut result = String::new(); - let mut token_usage = None; - while let Some(message) = stream.next().await.transpose()? { - match message { - ResponseEvent::OutputTextDelta(delta) => result.push_str(&delta), - ResponseEvent::OutputItemDone(item) => { - if result.is_empty() - && let ResponseItem::Message { content, .. } = item - && let Some(text) = crate::compact::content_items_to_text(&content) - { - result.push_str(&text); - } - } - ResponseEvent::Completed { - token_usage: usage, .. - } => { - token_usage = usage; - break; - } - _ => {} - } - } - - let mut output: StageOneOutput = serde_json::from_str(&result)?; - output.raw_memory = redact_secrets(output.raw_memory); - output.rollout_summary = redact_secrets(output.rollout_summary); - output.rollout_slug = output.rollout_slug.map(redact_secrets); - - Ok((output, token_usage)) - } - - mod result { - use super::*; - - pub(in crate::memories) async fn failed( - session: &Session, - thread_id: codex_protocol::ThreadId, - ownership_token: &str, - reason: &str, - ) { - tracing::warn!("Phase 1 job failed for thread {thread_id}: {reason}"); - if let Some(state_db) = session.services.state_db.as_deref() { - let _ = state_db - .mark_stage1_job_failed( - thread_id, - ownership_token, - reason, - phase_one::JOB_RETRY_DELAY_SECONDS, - ) - .await; - } - } - - pub(in crate::memories) async fn no_output( - session: &Session, - thread_id: codex_protocol::ThreadId, - ownership_token: &str, - ) -> JobOutcome { - let Some(state_db) = session.services.state_db.as_deref() else { - return JobOutcome::Failed; - }; - - if state_db - .mark_stage1_job_succeeded_no_output(thread_id, ownership_token) - .await - .unwrap_or(false) - { - JobOutcome::SucceededNoOutput - } else { - JobOutcome::Failed - } - } - - pub(in crate::memories) async fn success( - session: &Session, - thread_id: codex_protocol::ThreadId, - ownership_token: &str, - source_updated_at: i64, - raw_memory: &str, - rollout_summary: &str, - rollout_slug: Option<&str>, - ) -> JobOutcome { - let Some(state_db) = session.services.state_db.as_deref() else { - return JobOutcome::Failed; - }; - - if state_db - .mark_stage1_job_succeeded( - thread_id, - ownership_token, - source_updated_at, - raw_memory, - rollout_summary, - rollout_slug, - ) - .await - .unwrap_or(false) - { - JobOutcome::SucceededWithOutput - } else { - JobOutcome::Failed - } - } - } - - /// Serializes filtered stage-1 memory items for prompt inclusion. - pub(super) fn serialize_filtered_rollout_response_items( - items: &[RolloutItem], - ) -> codex_protocol::error::Result { - let filtered = items - .iter() - .filter_map(|item| { - if let RolloutItem::ResponseItem(item) = item { - sanitize_response_item_for_memories(item) - } else { - None - } - }) - .collect::>(); - let serialized = serde_json::to_string(&filtered).map_err(|err| { - CodexErr::InvalidRequest(format!("failed to serialize rollout memory: {err}")) - })?; - Ok(redact_secrets(serialized)) - } - - fn sanitize_response_item_for_memories(item: &ResponseItem) -> Option { - let ResponseItem::Message { - id, - role, - content, - phase, - } = item - else { - return should_persist_response_item_for_memories(item).then(|| item.clone()); - }; - - if role == "developer" { - return None; - } - - if role != "user" { - return Some(item.clone()); - } - - let content = content - .iter() - .filter(|content_item| !is_memory_excluded_contextual_user_fragment(content_item)) - .cloned() - .collect::>(); - if content.is_empty() { - return None; - } - - Some(ResponseItem::Message { - id: id.clone(), - role: role.clone(), - content, - phase: phase.clone(), - }) - } -} - -fn aggregate_stats(outcomes: Vec) -> Stats { - let claimed = outcomes.len(); - let mut succeeded_with_output = 0; - let mut succeeded_no_output = 0; - let mut failed = 0; - let mut total_token_usage = TokenUsage::default(); - let mut has_token_usage = false; - - for outcome in outcomes { - match outcome.outcome { - JobOutcome::SucceededWithOutput => succeeded_with_output += 1, - JobOutcome::SucceededNoOutput => succeeded_no_output += 1, - JobOutcome::Failed => failed += 1, - } - - if let Some(token_usage) = outcome.token_usage { - total_token_usage.add_assign(&token_usage); - has_token_usage = true; - } - } - - Stats { - claimed, - succeeded_with_output, - succeeded_no_output, - failed, - total_token_usage: has_token_usage.then_some(total_token_usage), - } -} - -fn emit_metrics(session: &Session, counts: &Stats) { - if counts.claimed > 0 { - session.services.session_telemetry.counter( - metrics::MEMORY_PHASE_ONE_JOBS, - counts.claimed as i64, - &[("status", "claimed")], - ); - } - if counts.succeeded_with_output > 0 { - session.services.session_telemetry.counter( - metrics::MEMORY_PHASE_ONE_JOBS, - counts.succeeded_with_output as i64, - &[("status", "succeeded")], - ); - session.services.session_telemetry.counter( - metrics::MEMORY_PHASE_ONE_OUTPUT, - counts.succeeded_with_output as i64, - &[], - ); - } - if counts.succeeded_no_output > 0 { - session.services.session_telemetry.counter( - metrics::MEMORY_PHASE_ONE_JOBS, - counts.succeeded_no_output as i64, - &[("status", "succeeded_no_output")], - ); - } - if counts.failed > 0 { - session.services.session_telemetry.counter( - metrics::MEMORY_PHASE_ONE_JOBS, - counts.failed as i64, - &[("status", "failed")], - ); - } - if let Some(token_usage) = counts.total_token_usage.as_ref() { - session.services.session_telemetry.histogram( - metrics::MEMORY_PHASE_ONE_TOKEN_USAGE, - token_usage.total_tokens.max(0), - &[("token_type", "total")], - ); - session.services.session_telemetry.histogram( - metrics::MEMORY_PHASE_ONE_TOKEN_USAGE, - token_usage.input_tokens.max(0), - &[("token_type", "input")], - ); - session.services.session_telemetry.histogram( - metrics::MEMORY_PHASE_ONE_TOKEN_USAGE, - token_usage.cached_input(), - &[("token_type", "cached_input")], - ); - session.services.session_telemetry.histogram( - metrics::MEMORY_PHASE_ONE_TOKEN_USAGE, - token_usage.output_tokens.max(0), - &[("token_type", "output")], - ); - session.services.session_telemetry.histogram( - metrics::MEMORY_PHASE_ONE_TOKEN_USAGE, - token_usage.reasoning_output_tokens.max(0), - &[("token_type", "reasoning_output")], - ); - } -} - -#[cfg(test)] -#[path = "phase1_tests.rs"] -mod tests; diff --git a/codex-rs/core/src/memories/phase1_tests.rs b/codex-rs/core/src/memories/phase1_tests.rs deleted file mode 100644 index 18a46d1740..0000000000 --- a/codex-rs/core/src/memories/phase1_tests.rs +++ /dev/null @@ -1,152 +0,0 @@ -use super::JobOutcome; -use super::JobResult; -use super::aggregate_stats; -use super::job::serialize_filtered_rollout_response_items; -use codex_protocol::models::ContentItem; -use codex_protocol::models::FunctionCallOutputBody; -use codex_protocol::models::FunctionCallOutputPayload; -use codex_protocol::models::ResponseItem; -use codex_protocol::protocol::RolloutItem; -use codex_protocol::protocol::TokenUsage; -use pretty_assertions::assert_eq; - -#[test] -fn serializes_memory_rollout_with_agents_removed_but_environment_kept() { - let mixed_contextual_message = ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ - ContentItem::InputText { - text: "# AGENTS.md instructions for /tmp\n\n\nbody\n" - .to_string(), - }, - ContentItem::InputText { - text: "\n/tmp\n".to_string(), - }, - ], - phase: None, - }; - let skill_message = ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "\ndemo\nskills/demo/SKILL.md\nbody\n" - .to_string(), - }], - phase: None, - }; - let subagent_message = ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "{\"agent_id\":\"a\",\"status\":\"completed\"}" - .to_string(), - }], - phase: None, - }; - - let serialized = serialize_filtered_rollout_response_items(&[ - RolloutItem::ResponseItem(mixed_contextual_message), - RolloutItem::ResponseItem(skill_message), - RolloutItem::ResponseItem(subagent_message.clone()), - ]) - .expect("serialize"); - let parsed: Vec = serde_json::from_str(&serialized).expect("parse"); - - assert_eq!( - parsed, - vec![ - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "\n/tmp\n" - .to_string(), - }], - phase: None, - }, - subagent_message, - ] - ); -} - -#[test] -fn serializes_memory_rollout_redacts_secrets_before_prompt_upload() { - let serialized = serialize_filtered_rollout_response_items(&[RolloutItem::ResponseItem( - ResponseItem::FunctionCallOutput { - call_id: "call_123".to_string(), - output: FunctionCallOutputPayload { - body: FunctionCallOutputBody::Text( - r#"{"token":"sk-abcdefghijklmnopqrstuvwxyz123456"}"#.to_string(), - ), - success: Some(true), - }, - }, - )]) - .expect("serialize"); - - assert!(!serialized.contains("sk-abcdefghijklmnopqrstuvwxyz123456")); - assert!(serialized.contains("[REDACTED_SECRET]")); -} - -#[test] -fn count_outcomes_sums_token_usage_across_all_jobs() { - let counts = aggregate_stats(vec![ - JobResult { - outcome: JobOutcome::SucceededWithOutput, - token_usage: Some(TokenUsage { - input_tokens: 10, - cached_input_tokens: 2, - output_tokens: 3, - reasoning_output_tokens: 1, - total_tokens: 13, - }), - }, - JobResult { - outcome: JobOutcome::SucceededNoOutput, - token_usage: Some(TokenUsage { - input_tokens: 7, - cached_input_tokens: 1, - output_tokens: 2, - reasoning_output_tokens: 0, - total_tokens: 9, - }), - }, - JobResult { - outcome: JobOutcome::Failed, - token_usage: None, - }, - ]); - - assert_eq!(counts.claimed, 3); - assert_eq!(counts.succeeded_with_output, 1); - assert_eq!(counts.succeeded_no_output, 1); - assert_eq!(counts.failed, 1); - assert_eq!( - counts.total_token_usage, - Some(TokenUsage { - input_tokens: 17, - cached_input_tokens: 3, - output_tokens: 5, - reasoning_output_tokens: 1, - total_tokens: 22, - }) - ); -} - -#[test] -fn count_outcomes_keeps_usage_empty_when_no_job_reports_it() { - let counts = aggregate_stats(vec![ - JobResult { - outcome: JobOutcome::SucceededWithOutput, - token_usage: None, - }, - JobResult { - outcome: JobOutcome::Failed, - token_usage: None, - }, - ]); - - assert_eq!(counts.claimed, 2); - assert_eq!(counts.total_token_usage, None); -} diff --git a/codex-rs/core/src/memories/start.rs b/codex-rs/core/src/memories/start.rs deleted file mode 100644 index 2cfaf9a875..0000000000 --- a/codex-rs/core/src/memories/start.rs +++ /dev/null @@ -1,44 +0,0 @@ -use crate::config::Config; -use crate::memories::phase1; -use crate::memories::phase2; -use crate::session::session::Session; -use codex_features::Feature; -use codex_protocol::protocol::SessionSource; -use std::sync::Arc; -use tracing::warn; - -/// Starts the asynchronous startup memory pipeline for an eligible root session. -/// -/// The pipeline is skipped for ephemeral sessions, disabled feature flags, and -/// subagent sessions. -pub(crate) fn start_memories_startup_task( - session: &Arc, - config: Arc, - source: &SessionSource, -) { - if config.ephemeral - || !config.features.enabled(Feature::MemoryTool) - || matches!(source, SessionSource::SubAgent(_)) - { - return; - } - - if session.services.state_db.is_none() { - warn!("state db unavailable for memories startup pipeline; skipping"); - return; - } - - let weak_session = Arc::downgrade(session); - tokio::spawn(async move { - let Some(session) = weak_session.upgrade() else { - return; - }; - - // Clean memories to make preserve DB size - phase1::prune(&session, &config).await; - // Run phase 1. - phase1::run(&session, &config).await; - // Run phase 2. - phase2::run(&session, config).await; - }); -} diff --git a/codex-rs/core/src/memories/tests.rs b/codex-rs/core/src/memories/tests.rs deleted file mode 100644 index cbf7222b1a..0000000000 --- a/codex-rs/core/src/memories/tests.rs +++ /dev/null @@ -1,1358 +0,0 @@ -use chrono::TimeZone; -use chrono::Utc; -use codex_config::types::DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION; -use codex_memories_write::clear_memory_roots_contents; -use codex_memories_write::ensure_layout; -use codex_memories_write::memory_root; -use codex_memories_write::raw_memories_file; -use codex_memories_write::rebuild_raw_memories_file_from_memories; -use codex_memories_write::rollout_summaries_dir; -use codex_memories_write::sync_rollout_summaries_from_memories; -use codex_protocol::ThreadId; -use codex_state::Stage1Output; -use codex_utils_absolute_path::AbsolutePathBuf; -use pretty_assertions::assert_eq; -use serde_json::Value; -use std::path::Path; -use std::path::PathBuf; -use tempfile::tempdir; - -#[test] -fn memory_root_uses_shared_global_path() { - let codex_home = AbsolutePathBuf::current_dir().expect("cwd").join("codex"); - assert_eq!(memory_root(&codex_home), codex_home.join("memories")); -} - -#[test] -fn stage_one_output_schema_requires_rollout_slug_and_keeps_it_nullable() { - let schema = crate::memories::phase1::output_schema(); - let properties = schema - .get("properties") - .and_then(Value::as_object) - .expect("properties object"); - let required = schema - .get("required") - .and_then(Value::as_array) - .expect("required array"); - - let mut required_keys = required - .iter() - .map(|key| key.as_str().expect("required key string")) - .collect::>(); - required_keys.sort_unstable(); - - assert!( - properties.contains_key("rollout_slug"), - "schema should declare rollout_slug" - ); - - let rollout_slug_type = properties - .get("rollout_slug") - .and_then(Value::as_object) - .and_then(|schema| schema.get("type")) - .and_then(Value::as_array) - .expect("rollout_slug type array"); - let mut rollout_slug_types = rollout_slug_type - .iter() - .map(|entry| entry.as_str().expect("type entry string")) - .collect::>(); - rollout_slug_types.sort_unstable(); - - assert_eq!( - required_keys, - vec!["raw_memory", "rollout_slug", "rollout_summary"] - ); - assert_eq!(rollout_slug_types, vec!["null", "string"]); -} - -#[tokio::test] -async fn clear_memory_root_contents_preserves_root_directory() { - let dir = tempdir().expect("tempdir"); - let root = dir.path().join("memories"); - let nested_dir = root.join("rollout_summaries"); - tokio::fs::create_dir_all(&nested_dir) - .await - .expect("create rollout summaries dir"); - tokio::fs::write(root.join("MEMORY.md"), "stale memory index\n") - .await - .expect("write memory index"); - tokio::fs::write(nested_dir.join("rollout.md"), "stale rollout\n") - .await - .expect("write rollout summary"); - - clear_memory_roots_contents(dir.path()) - .await - .expect("clear memory root contents"); - - assert!( - tokio::fs::try_exists(&root) - .await - .expect("check memory root existence"), - "memory root should still exist after clearing contents" - ); - let mut entries = tokio::fs::read_dir(&root) - .await - .expect("read memory root after clear"); - assert!( - entries - .next_entry() - .await - .expect("read next entry") - .is_none(), - "memory root should be empty after clearing contents" - ); -} - -#[cfg(unix)] -#[tokio::test] -async fn clear_memory_root_contents_rejects_symlinked_root() { - let dir = tempdir().expect("tempdir"); - let target = dir.path().join("outside"); - tokio::fs::create_dir_all(&target) - .await - .expect("create symlink target dir"); - let target_file = target.join("keep.txt"); - tokio::fs::write(&target_file, "keep\n") - .await - .expect("write target file"); - - let root = dir.path().join("memories"); - std::os::unix::fs::symlink(&target, &root).expect("create memory root symlink"); - - let err = clear_memory_roots_contents(dir.path()) - .await - .expect_err("symlinked memory root should be rejected"); - assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); - assert!( - tokio::fs::try_exists(&target_file) - .await - .expect("check target file existence"), - "rejecting a symlinked memory root should not delete the symlink target" - ); -} - -struct ConsolidatedOutputPaths { - memory_index: PathBuf, - memory_summary: PathBuf, - skill: PathBuf, -} - -async fn write_consolidated_outputs(root: &Path) -> ConsolidatedOutputPaths { - let paths = ConsolidatedOutputPaths { - memory_index: root.join("MEMORY.md"), - memory_summary: root.join("memory_summary.md"), - skill: root.join("skills/demo/SKILL.md"), - }; - - tokio::fs::write(&paths.memory_index, "consolidated memory index\n") - .await - .expect("write memory index"); - tokio::fs::write(&paths.memory_summary, "consolidated memory summary\n") - .await - .expect("write memory summary"); - tokio::fs::create_dir_all(paths.skill.parent().expect("skill parent")) - .await - .expect("create skill dir"); - tokio::fs::write(&paths.skill, "consolidated skill\n") - .await - .expect("write skill"); - - paths -} - -async fn assert_consolidated_outputs_exist(paths: &ConsolidatedOutputPaths, context: &str) { - assert!( - tokio::fs::try_exists(&paths.memory_index) - .await - .expect("check memory index existence"), - "{context} should leave MEMORY.md untouched" - ); - assert!( - tokio::fs::try_exists(&paths.memory_summary) - .await - .expect("check memory summary existence"), - "{context} should leave memory_summary.md untouched" - ); - assert!( - tokio::fs::try_exists(&paths.skill) - .await - .expect("check skill existence"), - "{context} should leave skills untouched" - ); -} - -#[tokio::test] -async fn sync_rollout_summaries_and_raw_memories_file_keeps_latest_memories_only() { - let dir = tempdir().expect("tempdir"); - let root = dir.path().join("memory"); - ensure_layout(&root).await.expect("ensure layout"); - - let keep_id = ThreadId::default().to_string(); - let drop_id = ThreadId::default().to_string(); - let keep_path = rollout_summaries_dir(&root).join(format!("{keep_id}.md")); - let drop_path = rollout_summaries_dir(&root).join(format!("{drop_id}.md")); - tokio::fs::write(&keep_path, "keep") - .await - .expect("write keep"); - tokio::fs::write(&drop_path, "drop") - .await - .expect("write drop"); - - let memories = vec![Stage1Output { - thread_id: ThreadId::try_from(keep_id.clone()).expect("thread id"), - source_updated_at: Utc.timestamp_opt(100, 0).single().expect("timestamp"), - raw_memory: "raw memory".to_string(), - rollout_summary: "short summary".to_string(), - rollout_slug: None, - rollout_path: PathBuf::from("/tmp/rollout-100.jsonl"), - cwd: PathBuf::from("/tmp/workspace"), - git_branch: None, - generated_at: Utc.timestamp_opt(101, 0).single().expect("timestamp"), - }]; - - sync_rollout_summaries_from_memories( - &root, - &memories, - DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION, - ) - .await - .expect("sync rollout summaries"); - rebuild_raw_memories_file_from_memories( - &root, - &memories, - DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION, - ) - .await - .expect("rebuild raw memories"); - - assert!( - !tokio::fs::try_exists(&keep_path) - .await - .expect("check stale keep path"), - "sync should prune stale filename that used thread id only" - ); - assert!( - !tokio::fs::try_exists(&drop_path) - .await - .expect("check stale drop path"), - "sync should prune stale filename for dropped thread" - ); - - let mut dir = tokio::fs::read_dir(rollout_summaries_dir(&root)) - .await - .expect("open rollout summaries dir"); - let mut files = Vec::new(); - while let Some(entry) = dir.next_entry().await.expect("read dir entry") { - files.push(entry.file_name().to_string_lossy().to_string()); - } - files.sort_unstable(); - assert_eq!(files.len(), 1); - let canonical_rollout_summary_file = &files[0]; - - let raw_memories = tokio::fs::read_to_string(raw_memories_file(&root)) - .await - .expect("read raw memories"); - assert!(raw_memories.contains("raw memory")); - assert!(raw_memories.contains(&keep_id)); - assert!(raw_memories.contains("cwd: /tmp/workspace")); - assert!(raw_memories.contains("rollout_path: /tmp/rollout-100.jsonl")); - assert!(raw_memories.contains(&format!( - "rollout_summary_file: {canonical_rollout_summary_file}" - ))); - let thread_header = format!("## Thread `{keep_id}`"); - let thread_pos = raw_memories - .find(&thread_header) - .expect("thread header should exist"); - let updated_pos = raw_memories[thread_pos..] - .find("updated_at: ") - .map(|offset| thread_pos + offset) - .expect("updated_at should exist after thread header"); - let cwd_pos = raw_memories[thread_pos..] - .find("cwd: /tmp/workspace") - .map(|offset| thread_pos + offset) - .expect("cwd should exist after thread header"); - let rollout_path_pos = raw_memories[thread_pos..] - .find("rollout_path: /tmp/rollout-100.jsonl") - .map(|offset| thread_pos + offset) - .expect("rollout_path should exist after thread header"); - let file_pos = raw_memories[thread_pos..] - .find(&format!( - "rollout_summary_file: {canonical_rollout_summary_file}" - )) - .map(|offset| thread_pos + offset) - .expect("rollout_summary_file should exist after thread header"); - assert!(thread_pos < updated_pos); - assert!(updated_pos < cwd_pos); - assert!(cwd_pos < rollout_path_pos); - assert!(rollout_path_pos < file_pos); -} - -#[tokio::test] -async fn sync_empty_inputs_preserves_consolidated_outputs() { - let dir = tempdir().expect("tempdir"); - let root = dir.path().join("memory"); - ensure_layout(&root).await.expect("ensure layout"); - - let stale_rollout_summary_path = rollout_summaries_dir(&root).join("stale.md"); - tokio::fs::write(&stale_rollout_summary_path, "stale summary\n") - .await - .expect("write stale rollout summary"); - let outputs = write_consolidated_outputs(&root).await; - - sync_rollout_summaries_from_memories( - &root, - &[], - DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION, - ) - .await - .expect("sync empty rollout summaries"); - rebuild_raw_memories_file_from_memories( - &root, - &[], - DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION, - ) - .await - .expect("rebuild empty raw memories"); - - assert!( - !tokio::fs::try_exists(&stale_rollout_summary_path) - .await - .expect("check stale rollout summary existence"), - "empty sync should prune stale rollout summaries" - ); - let raw_memories = tokio::fs::read_to_string(raw_memories_file(&root)) - .await - .expect("read raw memories"); - assert_eq!(raw_memories, "# Raw Memories\n\nNo raw memories yet.\n"); - assert_consolidated_outputs_exist(&outputs, "empty sync").await; -} - -#[tokio::test] -async fn sync_rollout_summaries_uses_timestamp_hash_and_sanitized_slug_filename() { - let dir = tempdir().expect("tempdir"); - let root = dir.path().join("memory"); - ensure_layout(&root).await.expect("ensure layout"); - - let thread_id = ThreadId::new(); - let stale_unslugged_path = rollout_summaries_dir(&root).join(format!("{thread_id}.md")); - let stale_old_slug_path = - rollout_summaries_dir(&root).join(format!("{thread_id}--old-slug.md")); - tokio::fs::write(&stale_unslugged_path, "stale") - .await - .expect("write stale unslugged file"); - tokio::fs::write(&stale_old_slug_path, "stale") - .await - .expect("write stale old-slug file"); - - let memories = vec![Stage1Output { - thread_id, - source_updated_at: Utc.timestamp_opt(200, 0).single().expect("timestamp"), - raw_memory: "raw memory".to_string(), - rollout_summary: "short summary".to_string(), - rollout_slug: Some("Unsafe Slug/With Spaces & Symbols + EXTRA_LONG_12345".to_string()), - rollout_path: PathBuf::from("/tmp/rollout-200.jsonl"), - cwd: PathBuf::from("/tmp/workspace"), - git_branch: Some("feature/memory-branch".to_string()), - generated_at: Utc.timestamp_opt(201, 0).single().expect("timestamp"), - }]; - - sync_rollout_summaries_from_memories( - &root, - &memories, - DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION, - ) - .await - .expect("sync rollout summaries"); - - let mut dir = tokio::fs::read_dir(rollout_summaries_dir(&root)) - .await - .expect("open rollout summaries dir"); - let mut files = Vec::new(); - while let Some(entry) = dir.next_entry().await.expect("read dir entry") { - files.push(entry.file_name().to_string_lossy().to_string()); - } - files.sort_unstable(); - - assert_eq!(files.len(), 1); - let file_name = &files[0]; - let stem = file_name - .strip_suffix(".md") - .expect("rollout summary file should end with .md"); - let (prefix, slug) = stem - .rsplit_once('-') - .expect("rollout summary filename should include slug"); - let (timestamp, short_hash) = prefix - .rsplit_once('-') - .expect("rollout summary filename should include short hash"); - - assert_eq!(timestamp.len(), 19, "timestamp should be second precision"); - let parsed_timestamp = chrono::NaiveDateTime::parse_from_str(timestamp, "%Y-%m-%dT%H-%M-%S"); - assert!( - parsed_timestamp.is_ok(), - "timestamp should use YYYY-MM-DDThh-mm-ss" - ); - assert_eq!(short_hash.len(), 4, "short hash should be exactly 4 chars"); - assert!( - short_hash.chars().all(|ch| ch.is_ascii_alphanumeric()), - "short hash should use only alphanumeric chars" - ); - assert!(slug.len() <= 60, "slug should be capped at 60 chars"); - assert!( - slug.chars() - .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_'), - "slug should be file-safe lowercase ascii with underscores" - ); - - let summary = tokio::fs::read_to_string(rollout_summaries_dir(&root).join(file_name)) - .await - .expect("read rollout summary"); - assert!(summary.contains(&format!("thread_id: {thread_id}"))); - assert!(summary.contains("rollout_path: /tmp/rollout-200.jsonl")); - assert!(summary.contains("git_branch: feature/memory-branch")); - assert!( - !tokio::fs::try_exists(&stale_unslugged_path) - .await - .expect("check stale unslugged path"), - "slugged sync should prune stale unslugged filename for same thread" - ); - assert!( - !tokio::fs::try_exists(&stale_old_slug_path) - .await - .expect("check stale old slug path"), - "slugged sync should prune stale slugged filename for same thread" - ); -} - -#[tokio::test] -async fn rebuild_raw_memories_file_adds_canonical_rollout_summary_file_header() { - let dir = tempdir().expect("tempdir"); - let root = dir.path().join("memory"); - ensure_layout(&root).await.expect("ensure layout"); - - let thread_id = - ThreadId::try_from("0194f5a6-89ab-7cde-8123-456789abcdef").expect("valid thread id"); - let memories = vec![Stage1Output { - thread_id, - source_updated_at: Utc.timestamp_opt(200, 0).single().expect("timestamp"), - raw_memory: "\ ---- -description: Added a migration test -keywords: codex-state, migrations ---- -### Task 1: migration-test -task: add-migration-test -task_group: codex-state -task_outcome: success -- Added regression coverage for migration uniqueness. - -### Task 2: validate-migration -task: validate-migration-ordering -task_group: codex-state -task_outcome: success -- Confirmed no ordering regressions." - .to_string(), - rollout_summary: "short summary".to_string(), - rollout_slug: Some("Unsafe Slug/With Spaces & Symbols + EXTRA_LONG_12345".to_string()), - rollout_path: PathBuf::from("/tmp/rollout-200.jsonl"), - cwd: PathBuf::from("/tmp/workspace"), - git_branch: None, - generated_at: Utc.timestamp_opt(201, 0).single().expect("timestamp"), - }]; - - sync_rollout_summaries_from_memories( - &root, - &memories, - DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION, - ) - .await - .expect("sync rollout summaries"); - rebuild_raw_memories_file_from_memories( - &root, - &memories, - DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION, - ) - .await - .expect("rebuild raw memories"); - - let mut dir = tokio::fs::read_dir(rollout_summaries_dir(&root)) - .await - .expect("open rollout summaries dir"); - let mut files = Vec::new(); - while let Some(entry) = dir.next_entry().await.expect("read dir entry") { - files.push(entry.file_name().to_string_lossy().to_string()); - } - files.sort_unstable(); - assert_eq!(files.len(), 1); - let canonical_rollout_summary_file = &files[0]; - - let raw_memories = tokio::fs::read_to_string(raw_memories_file(&root)) - .await - .expect("read raw memories"); - let summary = tokio::fs::read_to_string( - rollout_summaries_dir(&root).join(canonical_rollout_summary_file), - ) - .await - .expect("read rollout summary"); - assert!(summary.contains("rollout_path: /tmp/rollout-200.jsonl")); - assert!(raw_memories.contains(&format!( - "rollout_summary_file: {canonical_rollout_summary_file}" - ))); - assert!(raw_memories.contains("description: Added a migration test")); - assert!(raw_memories.contains("### Task 1: migration-test")); - assert!(raw_memories.contains("task: add-migration-test")); - assert!(raw_memories.contains("task_group: codex-state")); - assert!(raw_memories.contains("task_outcome: success")); -} - -mod phase2 { - use crate::ThreadManager; - use crate::agent::AgentControl; - use crate::config::Config; - use crate::config::test_config; - use crate::memories::phase2; - use crate::session::session::Session; - use crate::session::tests::make_session_and_context; - use chrono::Duration as ChronoDuration; - use chrono::Utc; - use codex_config::Constrained; - use codex_config::types::McpServerConfig; - use codex_features::Feature; - use codex_login::CodexAuth; - use codex_memories_write::memory_root; - use codex_memories_write::raw_memories_file; - use codex_memories_write::rebuild_raw_memories_file_from_memories; - use codex_memories_write::rollout_summaries_dir; - use codex_memories_write::sync_rollout_summaries_from_memories; - use codex_memories_write::workspace::prepare_memory_workspace; - use codex_protocol::AgentPath; - use codex_protocol::ThreadId; - use codex_protocol::models::PermissionProfile; - use codex_protocol::permissions::FileSystemSandboxPolicy; - use codex_protocol::permissions::NetworkSandboxPolicy; - use codex_protocol::protocol::AskForApproval; - use codex_protocol::protocol::Op; - use codex_protocol::protocol::SandboxPolicy; - use codex_protocol::protocol::SessionSource; - use codex_state::Phase2JobClaimOutcome; - use codex_state::Stage1Output; - use codex_state::ThreadMetadataBuilder; - use std::collections::HashMap; - use std::path::PathBuf; - use std::sync::Arc; - use tempfile::TempDir; - - fn stage1_output_with_source_updated_at(source_updated_at: i64) -> Stage1Output { - Stage1Output { - thread_id: ThreadId::new(), - source_updated_at: chrono::DateTime::::from_timestamp(source_updated_at, 0) - .expect("valid source_updated_at timestamp"), - raw_memory: "raw memory".to_string(), - rollout_summary: "rollout summary".to_string(), - rollout_slug: None, - rollout_path: PathBuf::from("/tmp/rollout-summary.jsonl"), - cwd: PathBuf::from("/tmp/workspace"), - git_branch: None, - generated_at: chrono::DateTime::::from_timestamp(source_updated_at + 1, 0) - .expect("valid generated_at timestamp"), - } - } - - struct DispatchHarness { - _codex_home: TempDir, - config: Arc, - session: Arc, - manager: ThreadManager, - state_db: Arc, - } - - impl DispatchHarness { - async fn new() -> Self { - Self::new_with_config(|_| {}).await - } - - async fn new_with_config(configure: impl FnOnce(&mut Config)) -> Self { - let codex_home = tempfile::tempdir().expect("create temp codex home"); - let mut config = test_config().await; - config.codex_home = - codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(codex_home.path()) - .expect("codex home is absolute"); - config.cwd = config.codex_home.clone(); - let permission_profile = PermissionProfile::from_runtime_permissions( - &FileSystemSandboxPolicy::unrestricted(), - NetworkSandboxPolicy::Enabled, - ); - config - .permissions - .set_permission_profile(permission_profile) - .expect("permissions are configurable"); - configure(&mut config); - let config = Arc::new(config); - - let state_db = codex_state::StateRuntime::init( - config.codex_home.to_path_buf(), - config.model_provider_id.clone(), - ) - .await - .expect("initialize state db"); - - let manager = ThreadManager::with_models_provider_and_home_for_tests( - CodexAuth::from_api_key("dummy"), - config.model_provider.clone(), - config.codex_home.to_path_buf(), - std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), - ); - let (mut session, _turn_context) = make_session_and_context().await; - session.services.state_db = Some(Arc::clone(&state_db)); - session.services.agent_control = manager.agent_control(); - - Self { - _codex_home: codex_home, - config, - session: Arc::new(session), - manager, - state_db, - } - } - - async fn seed_stage1_output(&self, source_updated_at: i64) -> ThreadId { - let thread_id = ThreadId::new(); - let mut metadata_builder = ThreadMetadataBuilder::new( - thread_id, - self.config - .codex_home - .join(format!("rollout-{thread_id}.jsonl")) - .to_path_buf(), - Utc::now(), - SessionSource::Cli, - ); - metadata_builder.cwd = self.config.cwd.to_path_buf(); - metadata_builder.model_provider = Some(self.config.model_provider_id.clone()); - let metadata = metadata_builder.build(&self.config.model_provider_id); - - self.state_db - .upsert_thread(&metadata) - .await - .expect("upsert thread metadata"); - - let claim = self - .state_db - .try_claim_stage1_job( - thread_id, - self.session.conversation_id, - source_updated_at, - /*lease_seconds*/ 3_600, - /*max_running_jobs*/ 64, - ) - .await - .expect("claim stage-1 job"); - let ownership_token = match claim { - codex_state::Stage1JobClaimOutcome::Claimed { ownership_token } => ownership_token, - other => panic!("unexpected stage-1 claim outcome: {other:?}"), - }; - assert!( - self.state_db - .mark_stage1_job_succeeded( - thread_id, - &ownership_token, - source_updated_at, - "raw memory", - "rollout summary", - /*rollout_slug*/ None, - ) - .await - .expect("mark stage-1 success"), - "stage-1 success should enqueue global consolidation" - ); - thread_id - } - - async fn shutdown_threads(&self) { - let report = self - .manager - .shutdown_all_threads_bounded(std::time::Duration::from_secs(10)) - .await; - assert!(report.submit_failed.is_empty()); - assert!(report.timed_out.is_empty()); - } - - fn user_input_ops_count(&self) -> usize { - self.manager - .captured_ops() - .into_iter() - .filter(|(_, op)| matches!(op, Op::UserInput { .. })) - .count() - } - } - - #[test] - fn completion_watermark_never_regresses_below_claimed_input_watermark() { - let stage1_output = stage1_output_with_source_updated_at(/*source_updated_at*/ 123); - - let completion = phase2::get_watermark(/*claimed_watermark*/ 1_000, &[stage1_output]); - pretty_assertions::assert_eq!(completion, 1_000); - } - - #[test] - fn completion_watermark_uses_claimed_watermark_when_there_are_no_memories() { - let completion = phase2::get_watermark(/*claimed_watermark*/ 777, &[]); - pretty_assertions::assert_eq!(completion, 777); - } - - #[test] - fn completion_watermark_uses_latest_memory_timestamp_when_it_is_newer() { - let older = stage1_output_with_source_updated_at(/*source_updated_at*/ 123); - let newer = stage1_output_with_source_updated_at(/*source_updated_at*/ 456); - - let completion = phase2::get_watermark(/*claimed_watermark*/ 200, &[older, newer]); - pretty_assertions::assert_eq!(completion, 456); - } - - #[tokio::test] - async fn dispatch_skips_when_memory_workspace_is_not_dirty() { - let harness = DispatchHarness::new().await; - let root = memory_root(&harness.config.codex_home); - rebuild_raw_memories_file_from_memories( - &root, - &[], - /*max_raw_memories_for_consolidation*/ 0, - ) - .await - .expect("write empty raw memories baseline"); - let outputs = super::write_consolidated_outputs(&root).await; - prepare_memory_workspace(&root) - .await - .expect("commit empty memory workspace as baseline"); - - phase2::run(&harness.session, Arc::clone(&harness.config)).await; - - pretty_assertions::assert_eq!(harness.user_input_ops_count(), 0); - super::assert_consolidated_outputs_exist(&outputs, "clean no-input phase2").await; - let thread_ids = harness.manager.list_thread_ids().await; - pretty_assertions::assert_eq!(thread_ids.len(), 0); - } - - #[tokio::test] - async fn dispatch_uses_git_dirty_state_without_db_dirty_watermark() { - let harness = DispatchHarness::new().await; - let root = memory_root(&harness.config.codex_home); - rebuild_raw_memories_file_from_memories( - &root, - &[], - /*max_raw_memories_for_consolidation*/ 0, - ) - .await - .expect("write empty raw memories baseline"); - prepare_memory_workspace(&root) - .await - .expect("commit empty memory workspace as baseline"); - let extension_resource = root - .join("extensions") - .join("chronicle") - .join("resources") - .join("2026-04-22T12-00-00-abcd-10min-memory.md"); - tokio::fs::create_dir_all( - extension_resource - .parent() - .expect("extension resource parent"), - ) - .await - .expect("create extension resource dir"); - tokio::fs::write( - root.join("extensions/chronicle/instructions.md"), - "instructions\n", - ) - .await - .expect("write extension instructions"); - tokio::fs::write(&extension_resource, "extension memory\n") - .await - .expect("write extension resource"); - - phase2::run(&harness.session, Arc::clone(&harness.config)).await; - - pretty_assertions::assert_eq!(harness.user_input_ops_count(), 1); - let workspace_diff = tokio::fs::read_to_string(root.join("phase2_workspace_diff.md")) - .await - .expect("read workspace diff"); - assert!( - workspace_diff.contains("- A extensions/chronicle/instructions.md"), - "git-only extension instructions should dirty phase2: {workspace_diff}" - ); - assert!( - workspace_diff.contains("- A extensions/chronicle/resources/"), - "git-only extension resource should dirty phase2: {workspace_diff}" - ); - let thread_ids = harness.manager.list_thread_ids().await; - pretty_assertions::assert_eq!(thread_ids.len(), 1); - - harness.shutdown_threads().await; - } - - #[tokio::test] - async fn dispatch_skips_when_global_job_is_already_running() { - let harness = DispatchHarness::new().await; - harness - .state_db - .enqueue_global_consolidation(/*input_watermark*/ 123) - .await - .expect("enqueue global consolidation"); - let claimed = harness - .state_db - .try_claim_global_phase2_job(ThreadId::new(), /*lease_seconds*/ 3_600) - .await - .expect("claim running global lock"); - assert!( - matches!(claimed, Phase2JobClaimOutcome::Claimed { .. }), - "precondition should claim the running lock" - ); - - phase2::run(&harness.session, Arc::clone(&harness.config)).await; - - let running_claim = harness - .state_db - .try_claim_global_phase2_job(ThreadId::new(), /*lease_seconds*/ 3_600) - .await - .expect("claim while lock is still running"); - pretty_assertions::assert_eq!(running_claim, Phase2JobClaimOutcome::SkippedRunning); - pretty_assertions::assert_eq!(harness.user_input_ops_count(), 0); - let thread_ids = harness.manager.list_thread_ids().await; - pretty_assertions::assert_eq!(thread_ids.len(), 0); - } - - #[tokio::test] - async fn dispatch_reclaims_stale_global_lock_and_starts_consolidation() { - let harness = DispatchHarness::new_with_config(|config| { - let server: McpServerConfig = - toml::from_str("command = \"docs-server\"").expect("deserialize MCP server"); - config - .mcp_servers - .set(HashMap::from([("docs".to_string(), server)])) - .expect("parent MCP servers are configurable"); - config - .features - .enable(Feature::Apps) - .expect("apps feature is configurable"); - config - .features - .enable(Feature::Plugins) - .expect("plugins feature is configurable"); - config.include_apps_instructions = true; - }) - .await; - harness.seed_stage1_output(Utc::now().timestamp()).await; - - let stale_claim = harness - .state_db - .try_claim_global_phase2_job(ThreadId::new(), /*lease_seconds*/ 0) - .await - .expect("claim stale global lock"); - assert!( - matches!(stale_claim, Phase2JobClaimOutcome::Claimed { .. }), - "stale lock precondition should be claimed" - ); - - phase2::run(&harness.session, Arc::clone(&harness.config)).await; - - let post_dispatch_claim = harness - .state_db - .try_claim_global_phase2_job(ThreadId::new(), /*lease_seconds*/ 3_600) - .await - .expect("claim after stale lock dispatch"); - assert!( - matches!( - post_dispatch_claim, - Phase2JobClaimOutcome::SkippedRunning - | Phase2JobClaimOutcome::SkippedRetryUnavailable - ), - "stale-lock dispatch should either keep the reclaimed job running or finish it before re-claim" - ); - - let user_input_ops = harness.user_input_ops_count(); - pretty_assertions::assert_eq!(user_input_ops, 1); - let thread_ids = harness.manager.list_thread_ids().await; - pretty_assertions::assert_eq!(thread_ids.len(), 1); - let thread_id = thread_ids[0]; - let subagent = harness - .manager - .get_thread(thread_id) - .await - .expect("get consolidation thread"); - let config_snapshot = subagent.config_snapshot().await; - pretty_assertions::assert_eq!(config_snapshot.approval_policy, AskForApproval::Never); - assert!(config_snapshot.ephemeral); - pretty_assertions::assert_eq!( - config_snapshot.cwd.as_path(), - memory_root(&harness.config.codex_home).as_path() - ); - let sandbox_policy = config_snapshot.sandbox_policy(); - match &sandbox_policy { - SandboxPolicy::WorkspaceWrite { network_access, .. } => { - assert!(!*network_access); - let effective_writable_roots: Vec<_> = sandbox_policy - .get_writable_roots_with_cwd(config_snapshot.cwd.as_path()) - .into_iter() - .map(|root| root.root) - .collect(); - pretty_assertions::assert_eq!( - effective_writable_roots.as_slice(), - [memory_root(&harness.config.codex_home)], - "consolidation subagent should only be able to write the memory root" - ); - } - other => panic!("unexpected sandbox policy: {other:?}"), - } - pretty_assertions::assert_eq!( - config_snapshot.session_source.get_agent_path(), - Some(AgentPath::morpheus()) - ); - assert!( - harness - .session - .services - .agent_control - .get_agent_metadata(thread_id) - .is_none(), - "memory consolidation should not be registered in the root collab agent registry" - ); - let turn_context = subagent.codex.session.new_default_turn().await; - let file_system_sandbox_policy = turn_context.file_system_sandbox_policy(); - let legacy_file_system_sandbox_policy = - FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( - &sandbox_policy, - config_snapshot.cwd.as_path(), - ); - assert!( - file_system_sandbox_policy.is_semantically_equivalent_to( - &legacy_file_system_sandbox_policy, - config_snapshot.cwd.as_path(), - ), - "consolidation subagent split filesystem policy should match the memory-root legacy policy" - ); - assert!( - file_system_sandbox_policy.can_write_path_with_cwd( - memory_root(&harness.config.codex_home).as_path(), - config_snapshot.cwd.as_path(), - ), - "consolidation subagent should be able to write the memory root" - ); - assert!( - !file_system_sandbox_policy.can_write_path_with_cwd( - harness.config.codex_home.join("config.toml").as_path(), - config_snapshot.cwd.as_path(), - ), - "consolidation subagent should not inherit codex_home write access" - ); - pretty_assertions::assert_eq!( - turn_context.network_sandbox_policy(), - NetworkSandboxPolicy::Restricted, - "consolidation subagent split network policy should preserve no-network sandboxing" - ); - assert!( - !turn_context.features.enabled(Feature::MemoryTool), - "consolidation subagent should have the memories feature disabled" - ); - assert!( - turn_context.config.mcp_servers.get().is_empty(), - "consolidation subagent should not inherit configured MCP servers" - ); - assert!( - !subagent - .codex - .session - .services - .mcp_connection_manager - .read() - .await - .has_servers(), - "consolidation subagent should not initialize MCP servers" - ); - assert!( - !turn_context.features.enabled(Feature::Apps), - "consolidation subagent should not expose app-backed MCP" - ); - assert!( - !turn_context.features.enabled(Feature::Plugins), - "consolidation subagent should not expose plugin-backed MCP" - ); - assert!( - !turn_context.config.include_apps_instructions, - "consolidation subagent should not include apps instructions" - ); - assert!( - !turn_context.config.memories.generate_memories, - "consolidation subagent should not generate memories" - ); - assert!( - !turn_context.config.memories.use_memories, - "consolidation subagent should not read memories" - ); - assert!( - subagent.rollout_path().is_none(), - "ephemeral consolidation thread should not materialize a rollout" - ); - let memory_mode = harness - .state_db - .get_thread_memory_mode(thread_id) - .await - .expect("read consolidation thread memory mode"); - pretty_assertions::assert_eq!(memory_mode, None); - - harness.shutdown_threads().await; - } - - #[tokio::test] - async fn dispatch_with_empty_stage1_outputs_spawns_for_workspace_changes() { - let harness = DispatchHarness::new().await; - let root = memory_root(&harness.config.codex_home); - let summaries_dir = rollout_summaries_dir(&root); - tokio::fs::create_dir_all(&summaries_dir) - .await - .expect("create rollout summaries dir"); - - let stale_summary_path = summaries_dir.join(format!("{}.md", ThreadId::new())); - tokio::fs::write(&stale_summary_path, "stale summary\n") - .await - .expect("write stale rollout summary"); - let raw_memories_path = raw_memories_file(&root); - tokio::fs::write(&raw_memories_path, "stale raw memories\n") - .await - .expect("write stale raw memories"); - let outputs = super::write_consolidated_outputs(&root).await; - - harness - .state_db - .enqueue_global_consolidation(/*input_watermark*/ 999) - .await - .expect("enqueue global consolidation"); - - phase2::run(&harness.session, Arc::clone(&harness.config)).await; - - assert!( - !tokio::fs::try_exists(&stale_summary_path) - .await - .expect("check stale summary existence"), - "empty consolidation should prune stale rollout summary files" - ); - let raw_memories = tokio::fs::read_to_string(&raw_memories_path) - .await - .expect("read rebuilt raw memories"); - pretty_assertions::assert_eq!(raw_memories, "# Raw Memories\n\nNo raw memories yet.\n"); - super::assert_consolidated_outputs_exist(&outputs, "empty consolidation").await; - let next_claim = harness - .state_db - .try_claim_global_phase2_job(ThreadId::new(), /*lease_seconds*/ 3_600) - .await - .expect("claim global job after empty consolidation dispatch"); - pretty_assertions::assert_eq!(next_claim, Phase2JobClaimOutcome::SkippedRunning); - pretty_assertions::assert_eq!(harness.user_input_ops_count(), 1); - let thread_ids = harness.manager.list_thread_ids().await; - pretty_assertions::assert_eq!(thread_ids.len(), 1); - - harness.shutdown_threads().await; - } - - #[tokio::test] - async fn dispatch_with_empty_selected_inputs_preserves_consolidated_outputs() { - let harness = DispatchHarness::new().await; - let source_updated_at = Utc::now().timestamp(); - let thread_id = harness.seed_stage1_output(source_updated_at).await; - let root = memory_root(&harness.config.codex_home); - let selected = harness - .state_db - .get_phase2_input_selection(/*n*/ 1, /*max_unused_days*/ 30) - .await - .expect("load phase2 input selection"); - sync_rollout_summaries_from_memories(&root, &selected, selected.len()) - .await - .expect("sync selected rollout summaries"); - rebuild_raw_memories_file_from_memories(&root, &selected, selected.len()) - .await - .expect("sync selected raw memories"); - let outputs = super::write_consolidated_outputs(&root).await; - prepare_memory_workspace(&root) - .await - .expect("commit current memory workspace as baseline"); - - let claim = harness - .state_db - .try_claim_global_phase2_job(ThreadId::new(), /*lease_seconds*/ 3_600) - .await - .expect("claim global phase2 job"); - let Phase2JobClaimOutcome::Claimed { - ownership_token, .. - } = claim - else { - panic!("unexpected phase2 claim outcome: {claim:?}"); - }; - assert!( - harness - .state_db - .mark_global_phase2_job_succeeded(&ownership_token, source_updated_at, &selected) - .await - .expect("mark phase2 succeeded"), - "phase2 success should update selected baseline" - ); - assert!( - harness - .state_db - .mark_thread_memory_mode_polluted(thread_id) - .await - .expect("mark thread polluted"), - "polluted selected thread should enqueue phase2 forgetting" - ); - - phase2::run(&harness.session, Arc::clone(&harness.config)).await; - - pretty_assertions::assert_eq!(harness.user_input_ops_count(), 1); - super::assert_consolidated_outputs_exist(&outputs, "empty selected phase2").await; - let workspace_diff = tokio::fs::read_to_string(root.join("phase2_workspace_diff.md")) - .await - .expect("read workspace diff"); - assert!( - workspace_diff.contains("- D rollout_summaries/"), - "empty selected phase2 should surface deleted rollout summaries: {workspace_diff}" - ); - assert!( - !workspace_diff.contains("- D MEMORY.md"), - "empty selected phase2 should not delete MEMORY.md directly: {workspace_diff}" - ); - assert!( - !workspace_diff.contains("- D memory_summary.md"), - "empty selected phase2 should not delete memory_summary.md directly: {workspace_diff}" - ); - assert!( - !workspace_diff.contains("- D skills/demo/SKILL.md"), - "empty selected phase2 should not delete skills directly: {workspace_diff}" - ); - - harness.shutdown_threads().await; - } - - #[tokio::test] - async fn dispatch_with_clean_workspace_rebuilds_selected_phase2_baseline() { - let harness = DispatchHarness::new().await; - let source_updated_at = (Utc::now() - ChronoDuration::days(1)).timestamp(); - let thread_id = harness.seed_stage1_output(source_updated_at).await; - let root = memory_root(&harness.config.codex_home); - let selected = harness - .state_db - .get_phase2_input_selection(/*n*/ 1, /*max_unused_days*/ 30) - .await - .expect("load phase2 input selection"); - - sync_rollout_summaries_from_memories(&root, &selected, selected.len()) - .await - .expect("sync selected rollout summaries"); - rebuild_raw_memories_file_from_memories(&root, &selected, selected.len()) - .await - .expect("sync selected raw memories"); - prepare_memory_workspace(&root) - .await - .expect("commit current memory workspace as baseline"); - - phase2::run(&harness.session, Arc::clone(&harness.config)).await; - - pretty_assertions::assert_eq!(harness.user_input_ops_count(), 0); - let pruned = harness - .state_db - .prune_stage1_outputs_for_retention(/*max_unused_days*/ 0, /*limit*/ 10) - .await - .expect("prune stage1 outputs after clean phase2"); - pretty_assertions::assert_eq!(pruned, 0); - - let selected = harness - .state_db - .get_phase2_input_selection(/*n*/ 1, /*max_unused_days*/ 30) - .await - .expect("load phase2 input selection after clean workspace success"); - pretty_assertions::assert_eq!(selected.len(), 1); - pretty_assertions::assert_eq!(selected[0].thread_id, thread_id); - } - - #[tokio::test] - async fn dispatch_marks_job_for_retry_when_sandbox_policy_cannot_be_overridden() { - let harness = DispatchHarness::new().await; - harness - .state_db - .enqueue_global_consolidation(/*input_watermark*/ 99) - .await - .expect("enqueue global consolidation"); - let mut constrained_config = harness.config.as_ref().clone(); - constrained_config.permissions.permission_profile = Constrained::allow_only( - PermissionProfile::from_legacy_sandbox_policy(&SandboxPolicy::DangerFullAccess), - ); - - phase2::run(&harness.session, Arc::new(constrained_config)).await; - - let retry_claim = harness - .state_db - .try_claim_global_phase2_job(ThreadId::new(), /*lease_seconds*/ 3_600) - .await - .expect("claim global job after sandbox policy failure"); - pretty_assertions::assert_eq!(retry_claim, Phase2JobClaimOutcome::SkippedRetryUnavailable); - pretty_assertions::assert_eq!(harness.user_input_ops_count(), 0); - let thread_ids = harness.manager.list_thread_ids().await; - pretty_assertions::assert_eq!(thread_ids.len(), 0); - } - - #[tokio::test] - async fn dispatch_marks_job_for_retry_when_syncing_artifacts_fails() { - let harness = DispatchHarness::new().await; - harness.seed_stage1_output(/*source_updated_at*/ 100).await; - let root = memory_root(&harness.config.codex_home); - tokio::fs::write(&root, "not a directory") - .await - .expect("create file at memory root"); - - phase2::run(&harness.session, Arc::clone(&harness.config)).await; - - let retry_claim = harness - .state_db - .try_claim_global_phase2_job(ThreadId::new(), /*lease_seconds*/ 3_600) - .await - .expect("claim global job after sync failure"); - pretty_assertions::assert_eq!(retry_claim, Phase2JobClaimOutcome::SkippedRetryUnavailable); - pretty_assertions::assert_eq!(harness.user_input_ops_count(), 0); - let thread_ids = harness.manager.list_thread_ids().await; - pretty_assertions::assert_eq!(thread_ids.len(), 0); - } - - #[tokio::test] - async fn dispatch_marks_job_for_retry_when_rebuilding_raw_memories_fails() { - let harness = DispatchHarness::new().await; - harness.seed_stage1_output(/*source_updated_at*/ 100).await; - let root = memory_root(&harness.config.codex_home); - tokio::fs::create_dir_all(raw_memories_file(&root)) - .await - .expect("create raw_memories.md as a directory"); - - phase2::run(&harness.session, Arc::clone(&harness.config)).await; - - let retry_claim = harness - .state_db - .try_claim_global_phase2_job(ThreadId::new(), /*lease_seconds*/ 3_600) - .await - .expect("claim global job after rebuild failure"); - pretty_assertions::assert_eq!(retry_claim, Phase2JobClaimOutcome::SkippedRetryUnavailable); - pretty_assertions::assert_eq!(harness.user_input_ops_count(), 0); - let thread_ids = harness.manager.list_thread_ids().await; - pretty_assertions::assert_eq!(thread_ids.len(), 0); - } - - #[tokio::test] - async fn dispatch_marks_job_for_retry_when_spawn_agent_fails() { - let codex_home = tempfile::tempdir().expect("create temp codex home"); - let mut config = test_config().await; - config.codex_home = - codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(codex_home.path()) - .expect("codex home is absolute"); - config.cwd = config.codex_home.clone(); - let config = Arc::new(config); - - let state_db = codex_state::StateRuntime::init( - config.codex_home.to_path_buf(), - config.model_provider_id.clone(), - ) - .await - .expect("initialize state db"); - - let (mut session, _turn_context) = make_session_and_context().await; - session.services.state_db = Some(Arc::clone(&state_db)); - session.services.agent_control = AgentControl::default(); - let session = Arc::new(session); - - let thread_id = ThreadId::new(); - let mut metadata_builder = ThreadMetadataBuilder::new( - thread_id, - config - .codex_home - .join(format!("rollout-{thread_id}.jsonl")) - .to_path_buf(), - Utc::now(), - SessionSource::Cli, - ); - metadata_builder.cwd = config.cwd.to_path_buf(); - metadata_builder.model_provider = Some(config.model_provider_id.clone()); - let metadata = metadata_builder.build(&config.model_provider_id); - state_db - .upsert_thread(&metadata) - .await - .expect("upsert thread metadata"); - - let claim = state_db - .try_claim_stage1_job( - thread_id, - session.conversation_id, - /*source_updated_at*/ 100, - /*lease_seconds*/ 3_600, - /*max_running_jobs*/ 64, - ) - .await - .expect("claim stage-1 job"); - let ownership_token = match claim { - codex_state::Stage1JobClaimOutcome::Claimed { ownership_token } => ownership_token, - other => panic!("unexpected stage-1 claim outcome: {other:?}"), - }; - assert!( - state_db - .mark_stage1_job_succeeded( - thread_id, - &ownership_token, - /*source_updated_at*/ 100, - "raw memory", - "rollout summary", - /*rollout_slug*/ None, - ) - .await - .expect("mark stage-1 success"), - "stage-1 success should enqueue global consolidation" - ); - - let chronicle_resources = config - .codex_home - .join("memories/extensions/chronicle/resources"); - tokio::fs::create_dir_all(&chronicle_resources) - .await - .expect("create chronicle resources"); - tokio::fs::write( - config - .codex_home - .join("memories/extensions/chronicle/instructions.md"), - "instructions", - ) - .await - .expect("write chronicle instructions"); - let old_file = chronicle_resources.join(format!( - "{}-abcd-10min-old.md", - (Utc::now() - ChronoDuration::days(8)).format("%Y-%m-%dT%H-%M-%S") - )); - tokio::fs::write(&old_file, "old resource") - .await - .expect("write old extension resource"); - - phase2::run(&session, Arc::clone(&config)).await; - - let retry_claim = state_db - .try_claim_global_phase2_job(ThreadId::new(), /*lease_seconds*/ 3_600) - .await - .expect("claim global job after spawn failure"); - pretty_assertions::assert_eq!( - retry_claim, - Phase2JobClaimOutcome::SkippedRetryUnavailable, - "spawn failures should leave the job in retry backoff instead of running" - ); - assert!( - !tokio::fs::try_exists(&old_file) - .await - .expect("check old extension resource"), - "old extension resources should still be pruned on failed phase2 attempts" - ); - let workspace_diff = - tokio::fs::read_to_string(config.codex_home.join("memories/phase2_workspace_diff.md")) - .await - .expect("read workspace diff"); - assert!( - workspace_diff.contains("- D extensions/chronicle/resources/"), - "spawn failures should keep a retryable workspace diff: {workspace_diff}" - ); - } -} diff --git a/codex-rs/core/src/rollout.rs b/codex-rs/core/src/rollout.rs index 4d282cdb55..d4ac5c699a 100644 --- a/codex-rs/core/src/rollout.rs +++ b/codex-rs/core/src/rollout.rs @@ -50,10 +50,6 @@ pub(crate) mod list { pub use codex_rollout::find_thread_path_by_id_str; } -pub(crate) mod policy { - pub use codex_rollout::should_persist_response_item_for_memories; -} - pub(crate) mod recorder { pub use codex_rollout::RolloutRecorder; } diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index d4ca521c95..70900b4465 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -670,66 +670,6 @@ pub async fn compact(sess: &Arc, sub_id: String) { .await; } -pub async fn drop_memories(sess: &Arc, config: &Arc, sub_id: String) { - let mut errors = Vec::new(); - - if let Some(state_db) = sess.services.state_db.as_deref() { - if let Err(err) = state_db.clear_memory_data().await { - errors.push(format!("failed clearing memory rows from state db: {err}")); - } - } else { - errors.push("state db unavailable; memory rows were not cleared".to_string()); - } - - if let Err(err) = codex_memories_write::clear_memory_roots_contents(&config.codex_home).await { - errors.push(format!( - "failed clearing memory directories under {}: {err}", - config.codex_home.display() - )); - } - - if errors.is_empty() { - let memory_root = codex_memories_write::memory_root(&config.codex_home); - sess.send_event_raw(Event { - id: sub_id, - msg: EventMsg::Warning(WarningEvent { - message: format!( - "Dropped memories at {} and cleared memory rows from state db.", - memory_root.display() - ), - }), - }) - .await; - return; - } - - sess.send_event_raw(Event { - id: sub_id, - msg: EventMsg::Error(ErrorEvent { - message: format!("Memory drop completed with errors: {}", errors.join("; ")), - codex_error_info: Some(CodexErrorInfo::Other), - }), - }) - .await; -} - -pub async fn update_memories(sess: &Arc, config: &Arc, sub_id: String) { - let session_source = { - let state = sess.state.lock().await; - state.session_configuration.session_source.clone() - }; - - crate::memories::start_memories_startup_task(sess, Arc::clone(config), &session_source); - - sess.send_event_raw(Event { - id: sub_id.clone(), - msg: EventMsg::Warning(WarningEvent { - message: "Memory update triggered.".to_string(), - }), - }) - .await; -} - pub async fn thread_rollback(sess: &Arc, sub_id: String, num_turns: u32) { if num_turns == 0 { sess.send_event_raw(Event { @@ -1181,14 +1121,6 @@ pub(super) async fn submission_loop( compact(&sess, sub.id.clone()).await; false } - Op::DropMemories => { - drop_memories(&sess, &config, sub.id.clone()).await; - false - } - Op::UpdateMemories => { - update_memories(&sess, &config, sub.id.clone()).await; - false - } Op::ThreadRollback { num_turns } => { thread_rollback(&sess, sub.id.clone(), num_turns).await; false diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index cb88f44461..8e76c7feeb 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -271,7 +271,6 @@ use crate::context::UserInstructions; use crate::exec_policy::ExecPolicyUpdateError; use crate::guardian::GuardianReviewSessionManager; use crate::mcp::McpManager; -use crate::memories; use crate::network_policy_decision::execpolicy_network_rule_amendment; use crate::plugins::PluginsManager; use crate::rollout::map_session_init_error; @@ -514,9 +513,10 @@ impl Codex { }; let config = Arc::new(config); - let refresh_strategy = match session_source { - SessionSource::SubAgent(_) => codex_models_manager::manager::RefreshStrategy::Offline, - _ => codex_models_manager::manager::RefreshStrategy::OnlineIfUncached, + let refresh_strategy = if session_source.is_non_root_agent() { + codex_models_manager::manager::RefreshStrategy::Offline + } else { + codex_models_manager::manager::RefreshStrategy::OnlineIfUncached }; if config.model.is_none() || !matches!( @@ -1142,10 +1142,10 @@ impl Session { let turn_context = self.new_default_turn().await; let is_subagent = { let state = self.state.lock().await; - matches!( - state.session_configuration.session_source, - SessionSource::SubAgent(_) - ) + state + .session_configuration + .session_source + .is_non_root_agent() }; let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); { diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 1d37db1b8d..9a13ce3351 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -425,10 +425,7 @@ impl Session { session_init.ephemeral = config.ephemeral, )); - let is_subagent = matches!( - session_configuration.session_source, - SessionSource::SubAgent(_) - ); + let is_subagent = session_configuration.session_source.is_non_root_agent(); let history_meta_fut = async { if is_subagent { (0, 0) @@ -989,12 +986,6 @@ impl Session { state.set_pending_session_start_source(Some(session_start_source)); } - memories::start_memories_startup_task( - &sess, - Arc::clone(&config), - &session_configuration.session_source, - ); - Ok(sess) } .await; diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 17fb16ad31..5d463f9346 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -211,9 +211,10 @@ pub struct ThreadManager { _test_codex_home_guard: Option, } -pub struct StartThreadWithToolsOptions { +pub struct StartThreadOptions { pub config: Config, pub initial_history: InitialHistory, + pub session_source: Option, pub dynamic_tools: Vec, pub persist_extended_history: bool, pub metrics_service_name: Option, @@ -540,34 +541,39 @@ impl ThreadManager { self.state.environment_manager.as_ref(), &config.cwd, ); - Box::pin( - self.start_thread_with_tools_and_service_name(StartThreadWithToolsOptions { - config, - initial_history: InitialHistory::New, - dynamic_tools, - persist_extended_history, - metrics_service_name: None, - parent_trace: None, - environments, - }), - ) + Box::pin(self.start_thread_with_options(StartThreadOptions { + config, + initial_history: InitialHistory::New, + session_source: None, + dynamic_tools, + persist_extended_history, + metrics_service_name: None, + parent_trace: None, + environments, + })) .await } - pub async fn start_thread_with_tools_and_service_name( + pub async fn start_thread_with_options( &self, - options: StartThreadWithToolsOptions, + options: StartThreadOptions, ) -> CodexResult { let thread_store = configured_thread_store(&options.config); - Box::pin(self.state.spawn_thread( + let session_source = options + .session_source + .unwrap_or_else(|| self.state.session_source.clone()); + Box::pin(self.state.spawn_thread_with_source( options.config, thread_store, options.initial_history, Arc::clone(&self.state.auth_manager), self.agent_control(), + session_source, options.dynamic_tools, options.persist_extended_history, options.metrics_service_name, + /*inherited_shell_snapshot*/ None, + /*inherited_exec_policy*/ None, options.parent_trace, options.environments, /*user_shell_override*/ None, @@ -831,16 +837,23 @@ impl ThreadManager { impl ThreadManagerState { pub(crate) async fn list_thread_ids(&self) -> Vec { - self.threads.read().await.keys().copied().collect() + self.threads + .read() + .await + .iter() + .filter_map(|(thread_id, thread)| { + (!thread.session_source.is_internal()).then_some(*thread_id) + }) + .collect() } /// Fetch a thread by ID or return ThreadNotFound. pub(crate) async fn get_thread(&self, thread_id: ThreadId) -> CodexResult> { let threads = self.threads.read().await; - threads - .get(&thread_id) - .cloned() - .ok_or_else(|| CodexErr::ThreadNotFound(thread_id)) + match threads.get(&thread_id) { + Some(thread) if !thread.session_source.is_internal() => Ok(thread.clone()), + Some(_) | None => Err(CodexErr::ThreadNotFound(thread_id)), + } } /// Send an operation to a thread by ID. @@ -1063,6 +1076,7 @@ impl ThreadManagerState { let parent_rollout_thread_trace = self .parent_rollout_thread_trace_for_source(&session_source, &initial_history) .await; + let tracked_session_source = session_source.clone(); let CodexSpawnOk { codex, thread_id, .. } = Codex::spawn(CodexSpawnArgs { @@ -1091,7 +1105,7 @@ impl ThreadManagerState { }) .await?; let new_thread = self - .finalize_thread_spawn(codex, thread_id, watch_registration) + .finalize_thread_spawn(codex, thread_id, tracked_session_source, watch_registration) .await?; if is_resumed_thread && let Err(err) = new_thread.thread.apply_goal_resume_runtime_effects().await @@ -1105,6 +1119,7 @@ impl ThreadManagerState { &self, codex: Codex, thread_id: ThreadId, + session_source: SessionSource, watch_registration: crate::file_watcher::WatchRegistration, ) -> CodexResult { let event = codex.next_event().await?; @@ -1121,6 +1136,7 @@ impl ThreadManagerState { let thread = Arc::new(CodexThread::new( codex, session_configured.rollout_path.clone(), + session_source, watch_registration, )); let mut threads = self.threads.write().await; diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index ef4420d1e1..9307927b03 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -13,6 +13,9 @@ use codex_protocol::models::ReasoningItemReasoningSummary; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelsResponse; use codex_protocol::protocol::AgentMessageEvent; +use codex_protocol::protocol::InitialHistory; +use codex_protocol::protocol::InternalSessionSource; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::TurnStartedEvent; use codex_protocol::protocol::UserMessageEvent; use core_test_support::PathBufExt; @@ -312,9 +315,10 @@ async fn start_thread_accepts_explicit_environment_when_default_environment_is_d ); let thread = manager - .start_thread_with_tools_and_service_name(StartThreadWithToolsOptions { + .start_thread_with_options(StartThreadOptions { config: config.clone(), initial_history: InitialHistory::New, + session_source: None, dynamic_tools: Vec::new(), persist_extended_history: false, metrics_service_name: None, @@ -330,6 +334,48 @@ async fn start_thread_accepts_explicit_environment_when_default_environment_is_d assert_eq!(manager.list_thread_ids().await, vec![thread.thread_id]); } +#[tokio::test] +async fn start_thread_keeps_internal_threads_hidden_from_normal_lookups() { + let temp_dir = tempdir().expect("tempdir"); + let mut config = test_config().await; + config.codex_home = temp_dir.path().join("codex-home").abs(); + config.cwd = config.codex_home.abs(); + std::fs::create_dir_all(&config.codex_home).expect("create codex home"); + + let manager = ThreadManager::with_models_provider_and_home_for_tests( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + config.codex_home.to_path_buf(), + Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + ); + let thread = manager + .start_thread_with_options(StartThreadOptions { + config, + initial_history: InitialHistory::New, + session_source: Some(SessionSource::Internal( + InternalSessionSource::MemoryConsolidation, + )), + dynamic_tools: Vec::new(), + persist_extended_history: false, + metrics_service_name: None, + parent_trace: None, + environments: Vec::new(), + }) + .await + .expect("internal thread should start"); + + assert_eq!(manager.list_thread_ids().await, Vec::new()); + assert!(manager.get_thread(thread.thread_id).await.is_err()); + + let report = manager + .shutdown_all_threads_bounded(Duration::from_secs(10)) + .await; + assert_eq!(report.completed, vec![thread.thread_id]); + assert!(report.submit_failed.is_empty()); + assert!(report.timed_out.is_empty()); + assert!(manager.list_thread_ids().await.is_empty()); +} + #[tokio::test] async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() { let temp_dir = tempdir().expect("tempdir"); @@ -357,9 +403,10 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() { let default_cwd = config.cwd.clone(); let source = manager - .start_thread_with_tools_and_service_name(StartThreadWithToolsOptions { + .start_thread_with_options(StartThreadOptions { config: config.clone(), initial_history: InitialHistory::New, + session_source: None, dynamic_tools: Vec::new(), persist_extended_history: false, metrics_service_name: None, diff --git a/codex-rs/core/src/tools/handlers/request_user_input.rs b/codex-rs/core/src/tools/handlers/request_user_input.rs index 69d222aa87..2c94145d31 100644 --- a/codex-rs/core/src/tools/handlers/request_user_input.rs +++ b/codex-rs/core/src/tools/handlers/request_user_input.rs @@ -5,7 +5,6 @@ use crate::tools::context::ToolPayload; use crate::tools::handlers::parse_arguments; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; -use codex_protocol::protocol::SessionSource; use codex_protocol::request_user_input::RequestUserInputArgs; use codex_tools::REQUEST_USER_INPUT_TOOL_NAME; use codex_tools::normalize_request_user_input_args; @@ -40,7 +39,7 @@ impl ToolHandler for RequestUserInputHandler { } }; - if matches!(turn.session_source, SessionSource::SubAgent(_)) { + if turn.session_source.is_non_root_agent() { return Err(FunctionCallError::RespondToModel( "request_user_input can only be used by the root thread".to_string(), )); diff --git a/codex-rs/core/src/tools/handlers/request_user_input_tests.rs b/codex-rs/core/src/tools/handlers/request_user_input_tests.rs index 3c21d66702..811e4a6686 100644 --- a/codex-rs/core/src/tools/handlers/request_user_input_tests.rs +++ b/codex-rs/core/src/tools/handlers/request_user_input_tests.rs @@ -4,6 +4,7 @@ use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; use crate::turn_diff_tracker::TurnDiffTracker; use codex_protocol::ThreadId; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index b4adc6559d..bcf4e3d558 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -54,7 +54,6 @@ mod items; mod json_result; mod live_cli; mod live_reload; -mod memories; mod model_overrides; mod model_switching; mod model_visible_layout; diff --git a/codex-rs/memories/write/Cargo.toml b/codex-rs/memories/write/Cargo.toml index 15deafd7e9..86ed831295 100644 --- a/codex-rs/memories/write/Cargo.toml +++ b/codex-rs/memories/write/Cargo.toml @@ -14,18 +14,32 @@ workspace = true [dependencies] anyhow = { workspace = true } chrono = { workspace = true } +codex-core = { workspace = true } +codex-config = { workspace = true } +codex-features = { workspace = true } codex-git-utils = { workspace = true } +codex-login = { workspace = true } +codex-otel = { workspace = true } codex-protocol = { workspace = true } +codex-rollout = { workspace = true } +codex-rollout-trace = { workspace = true } +codex-secrets = { workspace = true } codex-state = { workspace = true } +codex-terminal-detection = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-output-truncation = { workspace = true } codex-utils-template = { workspace = true } -tokio = { workspace = true, features = ["fs"] } +futures = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["fs", "rt", "sync", "time"] } tracing = { workspace = true, features = ["log"] } uuid = { workspace = true, features = ["v4", "v5"] } [dev-dependencies] codex-models-manager = { workspace = true } +core_test_support = { workspace = true } pretty_assertions = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true, features = ["fs", "macros"] } +wiremock = { workspace = true } diff --git a/codex-rs/memories/write/src/control.rs b/codex-rs/memories/write/src/control.rs index 4f09d3e74c..bc5d2dc90e 100644 --- a/codex-rs/memories/write/src/control.rs +++ b/codex-rs/memories/write/src/control.rs @@ -42,3 +42,75 @@ pub(crate) async fn clear_memory_root_contents(memory_root: &Path) -> std::io::R Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[tokio::test] + async fn clear_memory_root_contents_preserves_root_directory() { + let dir = tempdir().expect("tempdir"); + let root = dir.path().join("memories"); + let nested_dir = root.join("rollout_summaries"); + tokio::fs::create_dir_all(&nested_dir) + .await + .expect("create rollout summaries dir"); + tokio::fs::write(root.join("MEMORY.md"), "stale memory index\n") + .await + .expect("write memory index"); + tokio::fs::write(nested_dir.join("rollout.md"), "stale rollout\n") + .await + .expect("write rollout summary"); + + clear_memory_root_contents(&root) + .await + .expect("clear memory root contents"); + + assert!( + tokio::fs::try_exists(&root) + .await + .expect("check memory root existence"), + "memory root should still exist after clearing contents" + ); + let mut entries = tokio::fs::read_dir(&root) + .await + .expect("read memory root after clear"); + assert!( + entries + .next_entry() + .await + .expect("read next entry") + .is_none(), + "memory root should be empty after clearing contents" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn clear_memory_root_contents_rejects_symlinked_root() { + let dir = tempdir().expect("tempdir"); + let target = dir.path().join("outside"); + tokio::fs::create_dir_all(&target) + .await + .expect("create symlink target dir"); + let target_file = target.join("keep.txt"); + tokio::fs::write(&target_file, "keep\n") + .await + .expect("write target file"); + + let root = dir.path().join("memories"); + std::os::unix::fs::symlink(&target, &root).expect("create memory root symlink"); + + let err = clear_memory_root_contents(&root) + .await + .expect_err("symlinked memory root should be rejected"); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + assert!( + tokio::fs::try_exists(&target_file) + .await + .expect("check target file existence"), + "rejecting a symlinked memory root should not delete the symlink target" + ); + } +} diff --git a/codex-rs/memories/write/src/lib.rs b/codex-rs/memories/write/src/lib.rs index f15f58feec..be79663037 100644 --- a/codex-rs/memories/write/src/lib.rs +++ b/codex-rs/memories/write/src/lib.rs @@ -1,12 +1,16 @@ -//! Write-path helpers for Codex memories. +//! Write-path implementation for Codex memories. //! -//! This crate owns the file-backed memory artifact helpers, Phase 1 and Phase -//! 2 prompt rendering, extension pruning, and workspace diffing. Runtime -//! orchestration for Phase 1 and Phase 2 remains in `codex-core`. +//! This crate owns the startup memory pipeline, file-backed memory artifact +//! helpers, Phase 1 and Phase 2 prompt rendering, extension pruning, and +//! workspace diffing. mod control; mod extensions; +mod phase1; +mod phase2; mod prompts; +mod runtime; +mod start; mod storage; pub mod workspace; @@ -18,6 +22,7 @@ pub use control::clear_memory_roots_contents; pub use extensions::prune_old_extension_resources; pub use prompts::build_consolidation_prompt; pub use prompts::build_stage_one_input_message; +pub use start::start_memories_startup_task; pub use storage::rebuild_raw_memories_file_from_memories; pub use storage::rollout_summary_file_stem; pub use storage::sync_rollout_summaries_from_memories; @@ -36,6 +41,9 @@ pub const DEFAULT_STAGE_ONE_ROLLOUT_TOKEN_LIMIT: usize = 150_000; /// and model output. pub const STAGE_ONE_CONTEXT_WINDOW_PERCENT: i64 = 70; +#[cfg(test)] +mod startup_tests; + mod artifacts { pub(super) const EXTENSIONS_SUBDIR: &str = "extensions"; pub(super) const ROLLOUT_SUMMARIES_SUBDIR: &str = "rollout_summaries"; diff --git a/codex-rs/memories/write/src/phase1.rs b/codex-rs/memories/write/src/phase1.rs new file mode 100644 index 0000000000..120ff870b1 --- /dev/null +++ b/codex-rs/memories/write/src/phase1.rs @@ -0,0 +1,803 @@ +use crate::STAGE_ONE_PROMPT; +use crate::build_stage_one_input_message; +use crate::runtime::MemoryStartupContext; +use crate::runtime::StageOneRequestContext; +use codex_config::types::MemoriesConfig; +use codex_core::Prompt; +use codex_core::RolloutRecorder; +use codex_core::config::Config; +use codex_protocol::error::CodexErr; +use codex_protocol::models::BaseInstructions; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::TokenUsage; +use codex_rollout::INTERACTIVE_SESSION_SOURCES; +use codex_rollout::should_persist_response_item_for_memories; +use codex_secrets::redact_secrets; +use futures::StreamExt; +use serde::Deserialize; +use serde_json::Value; +use serde_json::json; +use std::path::Path; +use std::sync::Arc; +use tracing::info; +use tracing::warn; + +const MODEL: &str = "gpt-5.4-mini"; +const REASONING_EFFORT: ReasoningEffort = ReasoningEffort::Low; +const CONCURRENCY_LIMIT: usize = 8; +const JOB_LEASE_SECONDS: i64 = 3_600; +const JOB_RETRY_DELAY_SECONDS: i64 = 3_600; +const THREAD_SCAN_LIMIT: usize = 5_000; +const PRUNE_BATCH_SIZE: usize = 200; +const MEMORY_PHASE_ONE_JOBS: &str = "codex.memory.phase1"; +const MEMORY_PHASE_ONE_E2E_MS: &str = "codex.memory.phase1.e2e_ms"; +const MEMORY_PHASE_ONE_OUTPUT: &str = "codex.memory.phase1.output"; +const MEMORY_PHASE_ONE_TOKEN_USAGE: &str = "codex.memory.phase1.token_usage"; + +struct JobResult { + outcome: JobOutcome, + token_usage: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum JobOutcome { + SucceededWithOutput, + SucceededNoOutput, + Failed, +} + +struct Stats { + claimed: usize, + succeeded_with_output: usize, + succeeded_no_output: usize, + failed: usize, + total_token_usage: Option, +} + +/// Phase 1 model output payload. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +struct StageOneOutput { + /// Detailed markdown raw memory for a single rollout. + #[serde(rename = "raw_memory")] + pub(crate) raw_memory: String, + /// Compact summary line used for routing and indexing. + #[serde(rename = "rollout_summary")] + pub(crate) rollout_summary: String, + /// Optional slug used to derive rollout summary artifact filenames. + #[serde(default, rename = "rollout_slug")] + pub(crate) rollout_slug: Option, +} + +/// Runs memory phase 1 in strict step order: +/// 1) claim eligible rollout jobs +/// 2) build one stage-1 request context +/// 3) run stage-1 extraction jobs in parallel +/// 4) emit metrics and logs +pub async fn run(context: Arc, config: Arc) { + let stage_one_context = build_request_context(context.as_ref(), config.as_ref()).await; + let _phase_one_e2e_timer = stage_one_context.start_timer(MEMORY_PHASE_ONE_E2E_MS); + + // 1. Claim startup job. + let Some(claimed_candidates) = claim_startup_jobs(context.as_ref(), &config.memories).await + else { + return; + }; + if claimed_candidates.is_empty() { + stage_one_context.counter( + MEMORY_PHASE_ONE_JOBS, + /*inc*/ 1, + &[("status", "skipped_no_candidates")], + ); + return; + } + + // 3. Run the parallel sampling. + let outcomes = run_jobs( + context, + config, + claimed_candidates, + stage_one_context.clone(), + ) + .await; + + // 4. Metrics and logs. + let counts = aggregate_stats(outcomes); + emit_metrics(&stage_one_context, &counts); + info!( + "memory stage-1 extraction complete: {} job(s) claimed, {} succeeded ({} with output, {} no output), {} failed", + counts.claimed, + counts.succeeded_with_output + counts.succeeded_no_output, + counts.succeeded_with_output, + counts.succeeded_no_output, + counts.failed + ); +} + +/// Prune old un-used "dead" raw memories. +pub async fn prune(context: &MemoryStartupContext, config: &Config) { + if let Some(db) = context.state_db() { + let max_unused_days = config.memories.max_unused_days; + match db + .prune_stage1_outputs_for_retention(max_unused_days, PRUNE_BATCH_SIZE) + .await + { + Ok(pruned) => { + if pruned > 0 { + info!( + "memory startup pruned {pruned} stale stage-1 output row(s) older than {max_unused_days} days" + ); + } + } + Err(err) => { + warn!( + "state db prune_stage1_outputs_for_retention failed during memories startup: {err}" + ); + } + } + } +} + +/// JSON schema used to constrain phase-1 model output. +pub fn output_schema() -> Value { + json!({ + "type": "object", + "properties": { + "rollout_summary": { "type": "string" }, + "rollout_slug": { "type": ["string", "null"] }, + "raw_memory": { "type": "string" } + }, + "required": ["rollout_summary", "rollout_slug", "raw_memory"], + "additionalProperties": false + }) +} + +async fn claim_startup_jobs( + context: &MemoryStartupContext, + memories_config: &MemoriesConfig, +) -> Option> { + let Some(state_db) = context.state_db() else { + // This should not happen. + warn!("state db unavailable while claiming phase-1 startup jobs; skipping"); + return None; + }; + + let allowed_sources = INTERACTIVE_SESSION_SOURCES + .iter() + .map(ToString::to_string) + .collect::>(); + + match state_db + .claim_stage1_jobs_for_startup( + context.thread_id(), + codex_state::Stage1StartupClaimParams { + scan_limit: THREAD_SCAN_LIMIT, + max_claimed: memories_config.max_rollouts_per_startup, + max_age_days: memories_config.max_rollout_age_days, + min_rollout_idle_hours: memories_config.min_rollout_idle_hours, + allowed_sources: allowed_sources.as_slice(), + lease_seconds: JOB_LEASE_SECONDS, + }, + ) + .await + { + Ok(claims) => Some(claims), + Err(err) => { + warn!("state db claim_stage1_jobs_for_startup failed during memories startup: {err}"); + None + } + } +} + +async fn build_request_context( + context: &MemoryStartupContext, + config: &Config, +) -> StageOneRequestContext { + let model_name = config + .memories + .extract_model + .clone() + .unwrap_or(MODEL.to_string()); + context + .stage_one_request_context(config, &model_name, REASONING_EFFORT) + .await +} + +async fn run_jobs( + context: Arc, + config: Arc, + claimed_candidates: Vec, + stage_one_context: StageOneRequestContext, +) -> Vec { + futures::stream::iter(claimed_candidates.into_iter()) + .map(|claim| { + let context = Arc::clone(&context); + let config = Arc::clone(&config); + let stage_one_context = stage_one_context.clone(); + async move { + job::run(context.as_ref(), config.as_ref(), claim, &stage_one_context).await + } + }) + .buffer_unordered(CONCURRENCY_LIMIT) + .collect::>() + .await +} + +mod job { + use super::*; + + pub(crate) async fn run( + context: &MemoryStartupContext, + config: &Config, + claim: codex_state::Stage1JobClaim, + stage_one_context: &StageOneRequestContext, + ) -> JobResult { + let claimed_thread = claim.thread; + let (stage_one_output, token_usage) = match sample( + context, + config, + &claimed_thread.rollout_path, + &claimed_thread.cwd, + stage_one_context, + ) + .await + { + Ok(output) => output, + Err(reason) => { + result::failed( + context, + claimed_thread.id, + &claim.ownership_token, + &reason.to_string(), + ) + .await; + return JobResult { + outcome: JobOutcome::Failed, + token_usage: None, + }; + } + }; + + if stage_one_output.raw_memory.is_empty() || stage_one_output.rollout_summary.is_empty() { + return JobResult { + outcome: result::no_output(context, claimed_thread.id, &claim.ownership_token) + .await, + token_usage, + }; + } + + JobResult { + outcome: result::success( + context, + claimed_thread.id, + &claim.ownership_token, + claimed_thread.updated_at.timestamp(), + &stage_one_output.raw_memory, + &stage_one_output.rollout_summary, + stage_one_output.rollout_slug.as_deref(), + ) + .await, + token_usage, + } + } + + /// Extract the rollout and perform the actual sampling. + async fn sample( + context: &MemoryStartupContext, + config: &Config, + rollout_path: &Path, + rollout_cwd: &Path, + stage_one_context: &StageOneRequestContext, + ) -> anyhow::Result<(StageOneOutput, Option)> { + let (rollout_items, _, _) = RolloutRecorder::load_rollout_items(rollout_path).await?; + let rollout_contents = serialize_filtered_rollout_response_items(&rollout_items)?; + + let mut prompt = Prompt::default(); + prompt.input = vec![ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: build_stage_one_input_message( + &stage_one_context.model_info, + rollout_path, + rollout_cwd, + &rollout_contents, + )?, + }], + phase: None, + }]; + prompt.base_instructions = BaseInstructions { + text: STAGE_ONE_PROMPT.to_string(), + }; + prompt.output_schema = Some(output_schema()); + prompt.output_schema_strict = true; + + let (result, token_usage) = context + .stream_stage_one_prompt(config, &prompt, stage_one_context) + .await?; + + let mut output: StageOneOutput = serde_json::from_str(&result)?; + output.raw_memory = redact_secrets(output.raw_memory); + output.rollout_summary = redact_secrets(output.rollout_summary); + output.rollout_slug = output.rollout_slug.map(redact_secrets); + + Ok((output, token_usage)) + } + + mod result { + use super::*; + + pub(crate) async fn failed( + context: &MemoryStartupContext, + thread_id: codex_protocol::ThreadId, + ownership_token: &str, + reason: &str, + ) { + tracing::warn!("Phase 1 job failed for thread {thread_id}: {reason}"); + if let Some(state_db) = context.state_db() { + let _ = state_db + .mark_stage1_job_failed( + thread_id, + ownership_token, + reason, + JOB_RETRY_DELAY_SECONDS, + ) + .await; + } + } + + pub(crate) async fn no_output( + context: &MemoryStartupContext, + thread_id: codex_protocol::ThreadId, + ownership_token: &str, + ) -> JobOutcome { + let Some(state_db) = context.state_db() else { + return JobOutcome::Failed; + }; + + if state_db + .mark_stage1_job_succeeded_no_output(thread_id, ownership_token) + .await + .unwrap_or(false) + { + JobOutcome::SucceededNoOutput + } else { + JobOutcome::Failed + } + } + + pub(crate) async fn success( + context: &MemoryStartupContext, + thread_id: codex_protocol::ThreadId, + ownership_token: &str, + source_updated_at: i64, + raw_memory: &str, + rollout_summary: &str, + rollout_slug: Option<&str>, + ) -> JobOutcome { + let Some(state_db) = context.state_db() else { + return JobOutcome::Failed; + }; + + if state_db + .mark_stage1_job_succeeded( + thread_id, + ownership_token, + source_updated_at, + raw_memory, + rollout_summary, + rollout_slug, + ) + .await + .unwrap_or(false) + { + JobOutcome::SucceededWithOutput + } else { + JobOutcome::Failed + } + } + } + + /// Serializes filtered stage-1 memory items for prompt inclusion. + pub(super) fn serialize_filtered_rollout_response_items( + items: &[RolloutItem], + ) -> codex_protocol::error::Result { + let filtered = items + .iter() + .filter_map(|item| { + if let RolloutItem::ResponseItem(item) = item { + sanitize_response_item_for_memories(item) + } else { + None + } + }) + .collect::>(); + let serialized = serde_json::to_string(&filtered).map_err(|err| { + CodexErr::InvalidRequest(format!("failed to serialize rollout memory: {err}")) + })?; + Ok(redact_secrets(serialized)) + } + + fn sanitize_response_item_for_memories(item: &ResponseItem) -> Option { + let ResponseItem::Message { + id, + role, + content, + phase, + } = item + else { + return should_persist_response_item_for_memories(item).then(|| item.clone()); + }; + + if role == "developer" { + return None; + } + + if role != "user" { + return Some(item.clone()); + } + + let content = content + .iter() + .filter(|content_item| !is_memory_excluded_contextual_user_fragment(content_item)) + .cloned() + .collect::>(); + if content.is_empty() { + return None; + } + + Some(ResponseItem::Message { + id: id.clone(), + role: role.clone(), + content, + phase: phase.clone(), + }) + } + + fn is_memory_excluded_contextual_user_fragment(content_item: &ContentItem) -> bool { + let ContentItem::InputText { text } = content_item else { + return false; + }; + + matches_marked_fragment(text, "# AGENTS.md instructions for ", "") + || matches_marked_fragment(text, "", "") + } + + fn matches_marked_fragment(text: &str, start_marker: &str, end_marker: &str) -> bool { + let trimmed = text.trim_start(); + let starts_with_marker = trimmed + .get(..start_marker.len()) + .is_some_and(|candidate| candidate.eq_ignore_ascii_case(start_marker)); + let trimmed = trimmed.trim_end(); + let ends_with_marker = trimmed + .get(trimmed.len().saturating_sub(end_marker.len())..) + .is_some_and(|candidate| candidate.eq_ignore_ascii_case(end_marker)); + starts_with_marker && ends_with_marker + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn classifies_memory_excluded_fragments() { + let cases = [ + ( + "# AGENTS.md instructions for /tmp\n\n\nbody\n", + true, + ), + ( + "\ndemo\nskills/demo/SKILL.md\nbody\n", + true, + ), + ( + "\n/tmp\n", + false, + ), + ( + "{\"agent_id\":\"a\",\"status\":\"completed\"}", + false, + ), + ]; + + for (text, expected) in cases { + assert_eq!( + is_memory_excluded_contextual_user_fragment(&ContentItem::InputText { + text: text.to_string(), + }), + expected, + "{text}", + ); + } + } + + #[test] + fn output_schema_requires_rollout_slug_and_keeps_it_nullable() { + let schema = output_schema(); + let properties = schema + .get("properties") + .and_then(Value::as_object) + .expect("properties object"); + let required = schema + .get("required") + .and_then(Value::as_array) + .expect("required array"); + + let mut required_keys = required + .iter() + .map(|key| key.as_str().expect("required key string")) + .collect::>(); + required_keys.sort_unstable(); + + assert!( + properties.contains_key("rollout_slug"), + "schema should declare rollout_slug" + ); + + let rollout_slug_type = properties + .get("rollout_slug") + .and_then(Value::as_object) + .and_then(|entry| entry.get("type")) + .and_then(Value::as_array) + .expect("rollout_slug type array"); + let mut rollout_slug_types = rollout_slug_type + .iter() + .map(|entry| entry.as_str().expect("type entry string")) + .collect::>(); + rollout_slug_types.sort_unstable(); + + assert_eq!( + required_keys, + vec!["raw_memory", "rollout_slug", "rollout_summary"] + ); + assert_eq!(rollout_slug_types, vec!["null", "string"]); + } + } +} + +fn aggregate_stats(outcomes: Vec) -> Stats { + let claimed = outcomes.len(); + let mut succeeded_with_output = 0; + let mut succeeded_no_output = 0; + let mut failed = 0; + let mut total_token_usage = TokenUsage::default(); + let mut has_token_usage = false; + + for outcome in outcomes { + match outcome.outcome { + JobOutcome::SucceededWithOutput => succeeded_with_output += 1, + JobOutcome::SucceededNoOutput => succeeded_no_output += 1, + JobOutcome::Failed => failed += 1, + } + + if let Some(token_usage) = outcome.token_usage { + total_token_usage.add_assign(&token_usage); + has_token_usage = true; + } + } + + Stats { + claimed, + succeeded_with_output, + succeeded_no_output, + failed, + total_token_usage: has_token_usage.then_some(total_token_usage), + } +} + +fn emit_metrics(context: &StageOneRequestContext, counts: &Stats) { + if counts.claimed > 0 { + context.counter( + MEMORY_PHASE_ONE_JOBS, + counts.claimed as i64, + &[("status", "claimed")], + ); + } + if counts.succeeded_with_output > 0 { + context.counter( + MEMORY_PHASE_ONE_JOBS, + counts.succeeded_with_output as i64, + &[("status", "succeeded")], + ); + context.counter( + MEMORY_PHASE_ONE_OUTPUT, + counts.succeeded_with_output as i64, + &[], + ); + } + if counts.succeeded_no_output > 0 { + context.counter( + MEMORY_PHASE_ONE_JOBS, + counts.succeeded_no_output as i64, + &[("status", "succeeded_no_output")], + ); + } + if counts.failed > 0 { + context.counter( + MEMORY_PHASE_ONE_JOBS, + counts.failed as i64, + &[("status", "failed")], + ); + } + if let Some(token_usage) = counts.total_token_usage.as_ref() { + context.histogram( + MEMORY_PHASE_ONE_TOKEN_USAGE, + token_usage.total_tokens.max(0), + &[("token_type", "total")], + ); + context.histogram( + MEMORY_PHASE_ONE_TOKEN_USAGE, + token_usage.input_tokens.max(0), + &[("token_type", "input")], + ); + context.histogram( + MEMORY_PHASE_ONE_TOKEN_USAGE, + token_usage.cached_input(), + &[("token_type", "cached_input")], + ); + context.histogram( + MEMORY_PHASE_ONE_TOKEN_USAGE, + token_usage.output_tokens.max(0), + &[("token_type", "output")], + ); + context.histogram( + MEMORY_PHASE_ONE_TOKEN_USAGE, + token_usage.reasoning_output_tokens.max(0), + &[("token_type", "reasoning_output")], + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn serializes_memory_rollout_with_agents_removed_but_environment_kept() { + let mixed_contextual_message = ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ + ContentItem::InputText { + text: + "# AGENTS.md instructions for /tmp\n\n\nbody\n" + .to_string(), + }, + ContentItem::InputText { + text: "\n/tmp\n" + .to_string(), + }, + ], + phase: None, + }; + let skill_message = ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: + "\ndemo\nskills/demo/SKILL.md\nbody\n" + .to_string(), + }], + phase: None, + }; + let subagent_message = ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "{\"agent_id\":\"a\",\"status\":\"completed\"}" + .to_string(), + }], + phase: None, + }; + + let serialized = job::serialize_filtered_rollout_response_items(&[ + RolloutItem::ResponseItem(mixed_contextual_message), + RolloutItem::ResponseItem(skill_message), + RolloutItem::ResponseItem(subagent_message.clone()), + ]) + .expect("serialize"); + let parsed: Vec = serde_json::from_str(&serialized).expect("parse"); + + assert_eq!( + parsed, + vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "\n/tmp\n" + .to_string(), + }], + phase: None, + }, + subagent_message, + ] + ); + } + + #[test] + fn serializes_memory_rollout_redacts_secrets_before_prompt_upload() { + let serialized = + job::serialize_filtered_rollout_response_items(&[RolloutItem::ResponseItem( + ResponseItem::FunctionCallOutput { + call_id: "call_123".to_string(), + output: codex_protocol::models::FunctionCallOutputPayload { + body: codex_protocol::models::FunctionCallOutputBody::Text( + r#"{"token":"sk-abcdefghijklmnopqrstuvwxyz123456"}"#.to_string(), + ), + success: Some(true), + }, + }, + )]) + .expect("serialize"); + + assert!(!serialized.contains("sk-abcdefghijklmnopqrstuvwxyz123456")); + assert!(serialized.contains("[REDACTED_SECRET]")); + } + + #[test] + fn count_outcomes_sums_token_usage_across_all_jobs() { + let counts = aggregate_stats(vec![ + JobResult { + outcome: JobOutcome::SucceededWithOutput, + token_usage: Some(TokenUsage { + input_tokens: 10, + cached_input_tokens: 2, + output_tokens: 3, + reasoning_output_tokens: 1, + total_tokens: 13, + }), + }, + JobResult { + outcome: JobOutcome::SucceededNoOutput, + token_usage: Some(TokenUsage { + input_tokens: 7, + cached_input_tokens: 1, + output_tokens: 2, + reasoning_output_tokens: 0, + total_tokens: 9, + }), + }, + JobResult { + outcome: JobOutcome::Failed, + token_usage: None, + }, + ]); + + assert_eq!(counts.claimed, 3); + assert_eq!(counts.succeeded_with_output, 1); + assert_eq!(counts.succeeded_no_output, 1); + assert_eq!(counts.failed, 1); + assert_eq!( + counts.total_token_usage, + Some(TokenUsage { + input_tokens: 17, + cached_input_tokens: 3, + output_tokens: 5, + reasoning_output_tokens: 1, + total_tokens: 22, + }) + ); + } + + #[test] + fn count_outcomes_keeps_usage_empty_when_no_job_reports_it() { + let counts = aggregate_stats(vec![ + JobResult { + outcome: JobOutcome::SucceededWithOutput, + token_usage: None, + }, + JobResult { + outcome: JobOutcome::Failed, + token_usage: None, + }, + ]); + + assert_eq!(counts.claimed, 2); + assert_eq!(counts.total_token_usage, None); + } +} diff --git a/codex-rs/core/src/memories/phase2.rs b/codex-rs/memories/write/src/phase2.rs similarity index 57% rename from codex-rs/core/src/memories/phase2.rs rename to codex-rs/memories/write/src/phase2.rs index bcad738319..7278207f34 100644 --- a/codex-rs/core/src/memories/phase2.rs +++ b/codex-rs/memories/write/src/phase2.rs @@ -1,26 +1,21 @@ -use crate::agent::AgentStatus; -use crate::agent::status::is_final as is_final_agent_status; -use crate::config::Config; -use crate::memories::metrics; -use crate::memories::phase_two; -use crate::session::emit_subagent_session_started; -use crate::session::session::Session; +use crate::build_consolidation_prompt; +use crate::memory_root; +use crate::prune_old_extension_resources; +use crate::rebuild_raw_memories_file_from_memories; +use crate::runtime::MemoryStartupContext; +use crate::runtime::SpawnedConsolidationAgent; +use crate::sync_rollout_summaries_from_memories; +use crate::workspace::memory_workspace_diff; +use crate::workspace::prepare_memory_workspace; +use crate::workspace::reset_memory_workspace_baseline; +use crate::workspace::write_workspace_diff; use codex_config::Constrained; +use codex_core::config::Config; use codex_features::Feature; -use codex_memories_write::build_consolidation_prompt; -use codex_memories_write::memory_root; -use codex_memories_write::prune_old_extension_resources; -use codex_memories_write::rebuild_raw_memories_file_from_memories; -use codex_memories_write::sync_rollout_summaries_from_memories; -use codex_memories_write::workspace::memory_workspace_diff; -use codex_memories_write::workspace::prepare_memory_workspace; -use codex_memories_write::workspace::reset_memory_workspace_baseline; -use codex_memories_write::workspace::write_workspace_diff; use codex_protocol::ThreadId; +use codex_protocol::protocol::AgentStatus; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; -use codex_protocol::protocol::SessionSource; -use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TokenUsage; use codex_protocol::user_input::UserInput; use codex_state::Stage1Output; @@ -29,8 +24,16 @@ use std::collections::HashMap; use std::path::Path; use std::sync::Arc; use std::time::Duration; -use tokio::sync::watch; -use tracing::warn; +const MODEL: &str = "gpt-5.4"; +const REASONING_EFFORT: codex_protocol::openai_models::ReasoningEffort = + codex_protocol::openai_models::ReasoningEffort::Medium; +const JOB_LEASE_SECONDS: i64 = 3_600; +const JOB_RETRY_DELAY_SECONDS: i64 = 3_600; +const JOB_HEARTBEAT_SECONDS: u64 = 90; +const MEMORY_PHASE_TWO_JOBS: &str = "codex.memory.phase2"; +const MEMORY_PHASE_TWO_E2E_MS: &str = "codex.memory.phase2.e2e_ms"; +const MEMORY_PHASE_TWO_INPUT: &str = "codex.memory.phase2.input"; +const MEMORY_PHASE_TWO_TOKEN_USAGE: &str = "codex.memory.phase2.token_usage"; #[derive(Debug, Clone, Default)] struct Claim { @@ -45,14 +48,10 @@ struct Counters { /// Runs memory phase 2 (aka consolidation) in strict order. The method represents the linear /// flow of the consolidation phase. -pub(super) async fn run(session: &Arc, config: Arc) { - let phase_two_e2e_timer = session - .services - .session_telemetry - .start_timer(metrics::MEMORY_PHASE_TWO_E2E_MS, &[]) - .ok(); +pub async fn run(context: Arc, config: Arc) { + let phase_two_e2e_timer = context.start_timer(MEMORY_PHASE_TWO_E2E_MS); - let Some(db) = session.services.state_db.as_deref() else { + let Some(db) = context.state_db() else { // This should not happen. return; }; @@ -61,14 +60,10 @@ pub(super) async fn run(session: &Arc, config: Arc) { let max_unused_days = config.memories.max_unused_days; // 1. Claim the global Phase 2 lock before touching the memory workspace. - let claim = match job::claim(session, db).await { + let claim = match job::claim(context.as_ref(), db.as_ref()).await { Ok(claim) => claim, Err(e) => { - session.services.session_telemetry.counter( - metrics::MEMORY_PHASE_TWO_JOBS, - /*inc*/ 1, - &[("status", e)], - ); + context.counter(MEMORY_PHASE_TWO_JOBS, /*inc*/ 1, &[("status", e)]); return; } }; @@ -76,7 +71,13 @@ pub(super) async fn run(session: &Arc, config: Arc) { // 2. Ensure the memories root has a git baseline repository. if let Err(err) = prepare_memory_workspace(&root).await { tracing::error!("failed preparing memory workspace: {err}"); - job::failed(session, db, &claim, "failed_prepare_workspace").await; + job::failed( + context.as_ref(), + db.as_ref(), + &claim, + "failed_prepare_workspace", + ) + .await; return; } @@ -84,7 +85,13 @@ pub(super) async fn run(session: &Arc, config: Arc) { let Some(agent_config) = agent::get_config(config.as_ref()) else { // If we can't get the config, we can't consolidate. tracing::error!("failed to get agent config"); - job::failed(session, db, &claim, "failed_sandbox_policy").await; + job::failed( + context.as_ref(), + db.as_ref(), + &claim, + "failed_sandbox_policy", + ) + .await; return; }; @@ -96,7 +103,13 @@ pub(super) async fn run(session: &Arc, config: Arc) { Ok(raw_memories) => raw_memories, Err(err) => { tracing::error!("failed to list stage1 outputs from global: {err}"); - job::failed(session, db, &claim, "failed_load_stage1_outputs").await; + job::failed( + context.as_ref(), + db.as_ref(), + &claim, + "failed_load_stage1_outputs", + ) + .await; return; } }; @@ -106,7 +119,13 @@ pub(super) async fn run(session: &Arc, config: Arc) { // 5. Sync the current inputs into the memory workspace. if let Err(err) = sync_phase2_workspace_inputs(&root, &raw_memories).await { tracing::error!("failed syncing phase2 workspace inputs: {err}"); - job::failed(session, db, &claim, "failed_sync_workspace_inputs").await; + job::failed( + context.as_ref(), + db.as_ref(), + &claim, + "failed_sync_workspace_inputs", + ) + .await; return; } @@ -115,7 +134,13 @@ pub(super) async fn run(session: &Arc, config: Arc) { Ok(diff) => diff, Err(err) => { tracing::error!("failed checking memory workspace changes: {err}"); - job::failed(session, db, &claim, "failed_workspace_status").await; + job::failed( + context.as_ref(), + db.as_ref(), + &claim, + "failed_workspace_status", + ) + .await; return; } }; @@ -123,8 +148,8 @@ pub(super) async fn run(session: &Arc, config: Arc) { tracing::error!("Phase 2 no changes"); // We check only after sync of the file system. job::succeed( - session, - db, + context.as_ref(), + db.as_ref(), &claim, new_watermark, &raw_memories, @@ -137,54 +162,38 @@ pub(super) async fn run(session: &Arc, config: Arc) { // 7. Persist the diff for the consolidation agent to inspect. if let Err(err) = write_workspace_diff(&root, &workspace_diff).await { tracing::error!("failed writing memory workspace diff file: {err}"); - job::failed(session, db, &claim, "failed_workspace_diff_file").await; + job::failed( + context.as_ref(), + db.as_ref(), + &claim, + "failed_workspace_diff_file", + ) + .await; return; } // 8. Spawn the consolidation agent. let prompt = agent::get_prompt(&root); - let source = SessionSource::SubAgent(SubAgentSource::MemoryConsolidation); - let agent_control = session.services.agent_control.detached_registry(); - let thread_id = match agent_control - .spawn_agent(agent_config, prompt.into(), Some(source)) + let agent = match context + .spawn_consolidation_agent(agent_config, prompt) .await { - Ok(thread_id) => thread_id, + Ok(agent) => agent, Err(err) => { tracing::error!("failed to spawn global memory consolidation agent: {err}"); - job::failed(session, db, &claim, "failed_spawn_agent").await; + job::failed(context.as_ref(), db.as_ref(), &claim, "failed_spawn_agent").await; return; } }; - if let Some(thread_config) = session - .services - .agent_control - .get_agent_config_snapshot(thread_id) - .await - { - let client_metadata = session.app_server_client_metadata().await; - emit_subagent_session_started( - &session.services.analytics_events_client, - client_metadata, - thread_id, - /*parent_thread_id*/ None, - thread_config, - SubAgentSource::MemoryConsolidation, - ); - } else { - warn!("failed to load memory consolidation thread config for analytics: {thread_id}"); - } - // 9. Hand off completion handling, heartbeats, and baseline reset. agent::handle( - session, + Arc::clone(&context), claim, new_watermark, raw_memories.clone(), root, - thread_id, - agent_control, + agent, phase_two_e2e_timer, ); @@ -192,7 +201,7 @@ pub(super) async fn run(session: &Arc, config: Arc) { let counters = Counters { input: raw_memory_count as i64, }; - emit_metrics(session, counters); + emit_metrics(context.as_ref(), counters); } async fn sync_phase2_workspace_inputs( @@ -210,15 +219,14 @@ mod job { use super::*; pub(super) async fn claim( - session: &Arc, + context: &MemoryStartupContext, db: &StateRuntime, ) -> Result { - let session_telemetry = &session.services.session_telemetry; let claim = db - .try_claim_global_phase2_job(session.conversation_id, phase_two::JOB_LEASE_SECONDS) + .try_claim_global_phase2_job(context.thread_id(), JOB_LEASE_SECONDS) .await .map_err(|e| { - tracing::error!("failed to claim job: {}", e); + tracing::error!("failed to claim job: {e}"); "failed_claim" })?; let (token, watermark) = match claim { @@ -226,8 +234,8 @@ mod job { ownership_token, input_watermark, } => { - session_telemetry.counter( - metrics::MEMORY_PHASE_TWO_JOBS, + context.counter( + MEMORY_PHASE_TWO_JOBS, /*inc*/ 1, &[("status", "claimed")], ); @@ -243,48 +251,36 @@ mod job { } pub(super) async fn failed( - session: &Arc, + context: &MemoryStartupContext, db: &StateRuntime, claim: &Claim, reason: &'static str, ) { - session.services.session_telemetry.counter( - metrics::MEMORY_PHASE_TWO_JOBS, - /*inc*/ 1, - &[("status", reason)], - ); + context.counter(MEMORY_PHASE_TWO_JOBS, /*inc*/ 1, &[("status", reason)]); if matches!( - db.mark_global_phase2_job_failed( - &claim.token, - reason, - phase_two::JOB_RETRY_DELAY_SECONDS, - ) - .await, + db.mark_global_phase2_job_failed(&claim.token, reason, JOB_RETRY_DELAY_SECONDS,) + .await, Ok(false) ) { let _ = db .mark_global_phase2_job_failed_if_unowned( &claim.token, reason, - phase_two::JOB_RETRY_DELAY_SECONDS, + JOB_RETRY_DELAY_SECONDS, ) .await; } } pub(super) async fn succeed( - session: &Arc, + context: &MemoryStartupContext, db: &StateRuntime, claim: &Claim, completion_watermark: i64, selected_outputs: &[codex_state::Stage1Output], reason: &'static str, ) -> bool { - session.services.session_telemetry.counter( - metrics::MEMORY_PHASE_TWO_JOBS, - /*inc*/ 1, - &[("status", reason)], - ); + context.counter(MEMORY_PHASE_TWO_JOBS, /*inc*/ 1, &[("status", reason)]); db.mark_global_phase2_job_succeeded(&claim.token, completion_watermark, selected_outputs) .await .unwrap_or(false) @@ -293,6 +289,7 @@ mod job { mod agent { use super::*; + use tracing::warn; pub(super) fn get_config(config: &Config) -> Option { let root = memory_root(&config.codex_home); @@ -307,7 +304,7 @@ mod agent { agent_config.mcp_servers = Constrained::allow_only(HashMap::new()); // Approval policy agent_config.permissions.approval_policy = Constrained::allow_only(AskForApproval::Never); - // Consolidation runs as an internal sub-agent and must not recursively delegate. + // Consolidation runs as an internal worker and must not recursively delegate. let _ = agent_config.features.disable(Feature::SpawnCsv); let _ = agent_config.features.disable(Feature::Collab); let _ = agent_config.features.disable(Feature::MemoryTool); @@ -336,9 +333,9 @@ mod agent { .memories .consolidation_model .clone() - .unwrap_or(phase_two::MODEL.to_string()), + .unwrap_or(MODEL.to_string()), ); - agent_config.model_reasoning_effort = Some(phase_two::REASONING_EFFORT); + agent_config.model_reasoning_effort = Some(REASONING_EFFORT); Some(agent_config) } @@ -354,95 +351,90 @@ mod agent { /// Handle the agent while it is running. #[allow(clippy::too_many_arguments)] pub(super) fn handle( - session: &Arc, + context: Arc, claim: Claim, new_watermark: i64, selected_outputs: Vec, memory_root: codex_utils_absolute_path::AbsolutePathBuf, - thread_id: ThreadId, - agent_control: crate::agent::AgentControl, + agent: SpawnedConsolidationAgent, phase_two_e2e_timer: Option, ) { - let Some(db) = session.services.state_db.clone() else { + let Some(db) = context.state_db() else { return; }; - let session = session.clone(); tokio::spawn(async move { let _phase_two_e2e_timer = phase_two_e2e_timer; - - // TODO(jif) we might have a very small race here. - let rx = match agent_control.subscribe_status(thread_id).await { - Ok(rx) => rx, - Err(err) => { - tracing::error!("agent_control.subscribe_status failed: {err:?}"); - job::failed(&session, &db, &claim, "failed_subscribe_status").await; - return; - } - }; + let SpawnedConsolidationAgent { thread_id, thread } = agent; // Loop the agent until we have the final status. - let final_status = loop_agent(db.clone(), claim.token.clone(), thread_id, rx).await; + let final_status = + loop_agent(db.clone(), claim.token.clone(), thread_id, &thread).await; if matches!(final_status, AgentStatus::Completed(_)) { - if let Some(token_usage) = agent_control.get_total_token_usage(thread_id).await { - emit_token_usage_metrics(&session, &token_usage); + if let Some(token_usage) = thread + .token_usage_info() + .await + .map(|info| info.total_token_usage) + { + emit_token_usage_metrics(context.as_ref(), &token_usage); } // Do not reset the workspace baseline if we lost the lock. - let Ok(still_owns_lock) = db - .heartbeat_global_phase2_job(&claim.token, phase_two::JOB_LEASE_SECONDS) + let still_owns_lock = match db + .heartbeat_global_phase2_job(&claim.token, JOB_LEASE_SECONDS) .await .inspect_err(|err| { tracing::error!( "failed confirming global memory consolidation ownership before resetting workspace baseline: {err}" ); - }) - else { - job::failed(&session, &db, &claim, "failed_confirm_ownership").await; - return; + }) { + Ok(true) => true, + Ok(false) => { + tracing::error!( + "lost global memory consolidation ownership before resetting workspace baseline" + ); + false + } + Err(_) => { + job::failed(context.as_ref(), &db, &claim, "failed_confirm_ownership") + .await; + false + } }; - if !still_owns_lock { - tracing::error!( - "lost global memory consolidation ownership before resetting workspace baseline" - ); - return; - } - - if let Err(err) = reset_memory_workspace_baseline(&memory_root).await { - tracing::error!("failed resetting memory workspace baseline: {err}"); - job::failed(&session, &db, &claim, "failed_workspace_commit").await; - return; - } - if !job::succeed( - &session, - &db, - &claim, - new_watermark, - &selected_outputs, - "succeeded", - ) - .await - { - tracing::error!( - "failed marking global memory consolidation job succeeded after resetting workspace baseline" - ); - } - } else { - job::failed(&session, &db, &claim, "failed_agent").await; - } - - // Fire and forget close of the agent. - if !matches!(final_status, AgentStatus::Shutdown | AgentStatus::NotFound) { - tokio::spawn(async move { - if let Err(err) = agent_control.shutdown_live_agent(thread_id).await { - warn!( - "failed to auto-close global memory consolidation agent {thread_id}: {err}" + if still_owns_lock { + if let Err(err) = reset_memory_workspace_baseline(&memory_root).await { + tracing::error!("failed resetting memory workspace baseline: {err}"); + job::failed(context.as_ref(), &db, &claim, "failed_workspace_commit").await; + } else if !job::succeed( + context.as_ref(), + &db, + &claim, + new_watermark, + &selected_outputs, + "succeeded", + ) + .await + { + tracing::error!( + "failed marking global memory consolidation job succeeded after resetting workspace baseline" ); } - }); + } } else { - tracing::warn!("The agent was already gone"); + job::failed(context.as_ref(), &db, &claim, "failed_agent").await; } + + let cleanup_context = Arc::clone(&context); + tokio::spawn(async move { + if let Err(err) = cleanup_context + .shutdown_consolidation_agent(SpawnedConsolidationAgent { thread_id, thread }) + .await + { + warn!( + "failed to auto-close global memory consolidation agent {thread_id}: {err}" + ); + } + }); }); } @@ -450,42 +442,58 @@ mod agent { db: Arc, token: String, thread_id: ThreadId, - mut rx: watch::Receiver, + thread: &codex_core::CodexThread, ) -> AgentStatus { let mut heartbeat_interval = - tokio::time::interval(Duration::from_secs(phase_two::JOB_HEARTBEAT_SECONDS)); + tokio::time::interval(Duration::from_secs(JOB_HEARTBEAT_SECONDS)); heartbeat_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut status_poll_interval = tokio::time::interval(Duration::from_secs(1)); + status_poll_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let session_termination = thread.wait_until_terminated(); + tokio::pin!(session_termination); loop { - let status = rx.borrow().clone(); + let status = thread.agent_status().await; if is_final_agent_status(&status) { break status; } tokio::select! { - update = rx.changed() => { - if update.is_err() { - tracing::warn!( - "lost status updates for global memory consolidation agent {thread_id}" - ); + _ = &mut session_termination => { + let status = thread.agent_status().await; + if is_final_agent_status(&status) { break status; } + tracing::warn!( + "memory consolidation agent {thread_id} exited before final status; last status was {status:?}" + ); + break AgentStatus::Errored(format!( + "memory consolidation agent exited before final status: {status:?}" + )); + } + _ = status_poll_interval.tick() => { } _ = heartbeat_interval.tick() => { match db .heartbeat_global_phase2_job( &token, - phase_two::JOB_LEASE_SECONDS, + JOB_LEASE_SECONDS, ) .await { Ok(true) => {} Ok(false) => { + tracing::warn!( + "lost global phase-2 ownership during heartbeat for memory consolidation agent {thread_id}" + ); break AgentStatus::Errored( "lost global phase-2 ownership during heartbeat".to_string(), ); } Err(err) => { + tracing::warn!( + "phase-2 heartbeat update failed for memory consolidation agent {thread_id}: {err}" + ); break AgentStatus::Errored(format!( "phase-2 heartbeat update failed: {err}" )); @@ -509,43 +517,48 @@ pub(super) fn get_watermark( .max(claimed_watermark) } -fn emit_metrics(session: &Arc, counters: Counters) { - let otel = session.services.session_telemetry.clone(); +fn is_final_agent_status(status: &AgentStatus) -> bool { + !matches!( + status, + AgentStatus::PendingInit | AgentStatus::Running | AgentStatus::Interrupted + ) +} + +fn emit_metrics(context: &MemoryStartupContext, counters: Counters) { if counters.input > 0 { - otel.counter(metrics::MEMORY_PHASE_TWO_INPUT, counters.input, &[]); + context.counter(MEMORY_PHASE_TWO_INPUT, counters.input, &[]); } - otel.counter( - metrics::MEMORY_PHASE_TWO_JOBS, + context.counter( + MEMORY_PHASE_TWO_JOBS, /*inc*/ 1, &[("status", "agent_spawned")], ); } -fn emit_token_usage_metrics(session: &Arc, token_usage: &TokenUsage) { - let otel = session.services.session_telemetry.clone(); - otel.histogram( - metrics::MEMORY_PHASE_TWO_TOKEN_USAGE, +fn emit_token_usage_metrics(context: &MemoryStartupContext, token_usage: &TokenUsage) { + context.histogram( + MEMORY_PHASE_TWO_TOKEN_USAGE, token_usage.total_tokens.max(0), &[("token_type", "total")], ); - otel.histogram( - metrics::MEMORY_PHASE_TWO_TOKEN_USAGE, + context.histogram( + MEMORY_PHASE_TWO_TOKEN_USAGE, token_usage.input_tokens.max(0), &[("token_type", "input")], ); - otel.histogram( - metrics::MEMORY_PHASE_TWO_TOKEN_USAGE, + context.histogram( + MEMORY_PHASE_TWO_TOKEN_USAGE, token_usage.cached_input(), &[("token_type", "cached_input")], ); - otel.histogram( - metrics::MEMORY_PHASE_TWO_TOKEN_USAGE, + context.histogram( + MEMORY_PHASE_TWO_TOKEN_USAGE, token_usage.output_tokens.max(0), &[("token_type", "output")], ); - otel.histogram( - metrics::MEMORY_PHASE_TWO_TOKEN_USAGE, + context.histogram( + MEMORY_PHASE_TWO_TOKEN_USAGE, token_usage.reasoning_output_tokens.max(0), &[("token_type", "reasoning_output")], ); diff --git a/codex-rs/memories/write/src/runtime.rs b/codex-rs/memories/write/src/runtime.rs new file mode 100644 index 0000000000..737fb67870 --- /dev/null +++ b/codex-rs/memories/write/src/runtime.rs @@ -0,0 +1,293 @@ +use codex_core::CodexThread; +use codex_core::ModelClient; +use codex_core::NewThread; +use codex_core::Prompt; +use codex_core::ResponseEvent; +use codex_core::StartThreadOptions; +use codex_core::ThreadManager; +use codex_core::config::Config; +use codex_core::content_items_to_text; +use codex_core::resolve_installation_id; +use codex_features::Feature; +use codex_login::AuthManager; +use codex_login::CodexAuth; +use codex_login::auth_env_telemetry::collect_auth_env_telemetry; +use codex_login::default_client::originator; +use codex_otel::SessionTelemetry; +use codex_otel::TelemetryAuthMode; +use codex_protocol::ThreadId; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ModelInfo; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::InitialHistory; +use codex_protocol::protocol::InternalSessionSource; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::TokenUsage; +use codex_protocol::user_input::UserInput; +use codex_rollout_trace::InferenceTraceContext; +use codex_state::StateRuntime; +use codex_terminal_detection::user_agent; +use futures::StreamExt; +use std::sync::Arc; +use std::time::Duration; + +pub(crate) struct SpawnedConsolidationAgent { + pub(crate) thread_id: ThreadId, + pub(crate) thread: Arc, +} + +#[derive(Clone, Debug)] +pub(crate) struct StageOneRequestContext { + pub(crate) model_info: ModelInfo, + pub(crate) session_telemetry: SessionTelemetry, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: ReasoningSummary, + pub(crate) service_tier: Option, + pub(crate) turn_metadata_header: Option, +} + +impl StageOneRequestContext { + pub(crate) fn start_timer(&self, name: &str) -> Option { + self.session_telemetry.start_timer(name, &[]).ok() + } + + pub(crate) fn counter(&self, name: &str, inc: i64, tags: &[(&str, &str)]) { + self.session_telemetry.counter(name, inc, tags); + } + + pub(crate) fn histogram(&self, name: &str, value: i64, tags: &[(&str, &str)]) { + self.session_telemetry.histogram(name, value, tags); + } +} + +pub(crate) struct MemoryStartupContext { + thread_id: ThreadId, + thread: Arc, + thread_manager: Arc, + auth_manager: Arc, + session_telemetry: SessionTelemetry, +} + +impl MemoryStartupContext { + pub(crate) fn new( + thread_manager: Arc, + auth_manager: Arc, + thread_id: ThreadId, + thread: Arc, + config: &Config, + source: SessionSource, + ) -> Self { + let auth = auth_manager.auth_cached(); + let auth = auth.as_ref(); + let auth_mode = auth.map(CodexAuth::auth_mode).map(TelemetryAuthMode::from); + let account_id = auth.and_then(CodexAuth::get_account_id); + let account_email = auth.and_then(CodexAuth::get_account_email); + let model = config.model.as_deref().unwrap_or("unknown"); + let auth_env_telemetry = collect_auth_env_telemetry( + &config.model_provider, + auth_manager.codex_api_key_env_enabled(), + ); + let session_telemetry = SessionTelemetry::new( + thread_id, + model, + model, + account_id, + account_email, + auth_mode, + originator().value, + config.otel.log_user_prompt, + user_agent(), + source, + ) + .with_auth_env(auth_env_telemetry.to_otel_metadata()); + + Self { + thread_id, + thread, + thread_manager, + auth_manager, + session_telemetry, + } + } + + pub(crate) fn thread_id(&self) -> ThreadId { + self.thread_id + } + + pub(crate) fn state_db(&self) -> Option> { + self.thread.state_db() + } + + pub(crate) fn counter(&self, name: &str, inc: i64, tags: &[(&str, &str)]) { + self.session_telemetry.counter(name, inc, tags); + } + + pub(crate) fn histogram(&self, name: &str, value: i64, tags: &[(&str, &str)]) { + self.session_telemetry.histogram(name, value, tags); + } + + pub(crate) fn start_timer(&self, name: &str) -> Option { + self.session_telemetry.start_timer(name, &[]).ok() + } + + pub(crate) async fn stage_one_request_context( + &self, + config: &Config, + model_name: &str, + reasoning_effort: ReasoningEffort, + ) -> StageOneRequestContext { + let config_snapshot = self.thread.config_snapshot().await; + let model_info = self + .thread_manager + .get_models_manager() + .get_model_info(model_name, &config.to_models_manager_config()) + .await; + let turn_metadata_header = + codex_core::build_turn_metadata_header(&config.cwd, /*sandbox*/ None).await; + let reasoning_summary = config + .model_reasoning_summary + .unwrap_or(model_info.default_reasoning_summary); + + StageOneRequestContext { + model_info, + turn_metadata_header, + session_telemetry: self + .session_telemetry + .clone() + .with_model(model_name, model_name), + reasoning_effort: Some(reasoning_effort), + reasoning_summary, + service_tier: config_snapshot.service_tier, + } + } + + pub(crate) async fn stream_stage_one_prompt( + &self, + config: &Config, + prompt: &Prompt, + context: &StageOneRequestContext, + ) -> anyhow::Result<(String, Option)> { + let installation_id = resolve_installation_id(&config.codex_home).await?; + let session_source = self.thread.config_snapshot().await.session_source; + let model_client = ModelClient::new( + Some(Arc::clone(&self.auth_manager)), + self.thread_id, + installation_id, + config.model_provider.clone(), + session_source, + config.model_verbosity, + config.features.enabled(Feature::EnableRequestCompression), + config.features.enabled(Feature::RuntimeMetrics), + /*beta_features_header*/ None, + ); + + let mut client_session = model_client.new_session(); + let mut stream = client_session + .stream( + prompt, + &context.model_info, + &context.session_telemetry, + context.reasoning_effort, + context.reasoning_summary, + context.service_tier, + context.turn_metadata_header.as_deref(), + &InferenceTraceContext::disabled(), + ) + .await?; + + let mut result = String::new(); + let mut token_usage = None; + while let Some(message) = stream.next().await.transpose()? { + match message { + ResponseEvent::OutputTextDelta(delta) => result.push_str(&delta), + ResponseEvent::OutputItemDone(item) => { + if result.is_empty() + && let codex_protocol::models::ResponseItem::Message { content, .. } = item + && let Some(text) = content_items_to_text(&content) + { + result.push_str(&text); + } + } + ResponseEvent::Completed { + token_usage: usage, .. + } => { + token_usage = usage; + break; + } + _ => {} + } + } + + Ok((result, token_usage)) + } + + pub(crate) async fn spawn_consolidation_agent( + &self, + config: Config, + prompt: Vec, + ) -> anyhow::Result { + let environments = self + .thread_manager + .default_environment_selections(&config.cwd); + let NewThread { + thread_id, thread, .. + } = self + .thread_manager + .start_thread_with_options(StartThreadOptions { + config, + initial_history: InitialHistory::New, + session_source: Some(SessionSource::Internal( + InternalSessionSource::MemoryConsolidation, + )), + dynamic_tools: Vec::new(), + persist_extended_history: false, + metrics_service_name: None, + parent_trace: None, + environments, + }) + .await?; + + let agent = SpawnedConsolidationAgent { thread_id, thread }; + if let Err(err) = agent + .thread + .submit(Op::UserInput { + items: prompt, + environments: None, + final_output_json_schema: None, + responsesapi_client_metadata: None, + }) + .await + { + if let Err(shutdown_err) = self.shutdown_consolidation_agent(agent).await { + tracing::warn!( + "failed to shut down consolidation agent after submit error: {shutdown_err}" + ); + } + return Err(err.into()); + } + + Ok(agent) + } + + pub(crate) async fn shutdown_consolidation_agent( + &self, + agent: SpawnedConsolidationAgent, + ) -> anyhow::Result<()> { + let SpawnedConsolidationAgent { thread_id, thread } = agent; + let thread = self + .thread_manager + .remove_thread(&thread_id) + .await + .unwrap_or(thread); + + tokio::time::timeout(Duration::from_secs(10), thread.shutdown_and_wait()) + .await + .map_err(|_| { + anyhow::anyhow!("memory consolidation agent {thread_id} shutdown timed out") + })??; + + Ok(()) + } +} diff --git a/codex-rs/memories/write/src/start.rs b/codex-rs/memories/write/src/start.rs new file mode 100644 index 0000000000..362ce9fa43 --- /dev/null +++ b/codex-rs/memories/write/src/start.rs @@ -0,0 +1,55 @@ +use crate::phase1; +use crate::phase2; +use crate::runtime::MemoryStartupContext; +use codex_core::CodexThread; +use codex_core::ThreadManager; +use codex_core::config::Config; +use codex_features::Feature; +use codex_login::AuthManager; +use codex_protocol::ThreadId; +use codex_protocol::protocol::SessionSource; +use std::sync::Arc; +use tracing::warn; + +/// Starts the asynchronous startup memory pipeline for an eligible root session. +/// +/// The pipeline is skipped for ephemeral sessions, disabled feature flags, and +/// subagent sessions. +pub fn start_memories_startup_task( + thread_manager: Arc, + auth_manager: Arc, + thread_id: ThreadId, + thread: Arc, + config: Arc, + source: &SessionSource, +) { + if config.ephemeral + || !config.features.enabled(Feature::MemoryTool) + || source.is_non_root_agent() + { + return; + } + + let context = Arc::new(MemoryStartupContext::new( + thread_manager, + auth_manager, + thread_id, + thread, + config.as_ref(), + source.clone(), + )); + + if context.state_db().is_none() { + warn!("state db unavailable for memories startup pipeline; skipping"); + return; + } + + tokio::spawn(async move { + // Clean memories to make preserve DB size + phase1::prune(context.as_ref(), &config).await; + // Run phase 1. + phase1::run(Arc::clone(&context), Arc::clone(&config)).await; + // Run phase 2. + phase2::run(context, config).await; + }); +} diff --git a/codex-rs/core/tests/suite/memories.rs b/codex-rs/memories/write/src/startup_tests.rs similarity index 68% rename from codex-rs/core/tests/suite/memories.rs rename to codex-rs/memories/write/src/startup_tests.rs index a585ed24c5..d89b68825e 100644 --- a/codex-rs/core/tests/suite/memories.rs +++ b/codex-rs/memories/write/src/startup_tests.rs @@ -1,10 +1,10 @@ -use anyhow::Result; -use chrono::Duration as ChronoDuration; -use chrono::Utc; +use crate::start_memories_startup_task; use codex_features::Feature; use codex_git_utils::diff_since_latest_init; use codex_git_utils::reset_git_repository; use codex_protocol::ThreadId; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::protocol::SessionSource; @@ -26,18 +26,18 @@ use tempfile::TempDir; use tokio::time::Duration; use tokio::time::Instant; -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn memories_startup_phase2_tracks_workspace_diff_across_runs() -> Result<()> { +#[tokio::test] +async fn memories_startup_phase2_tracks_workspace_diff_across_runs() -> anyhow::Result<()> { let server = start_mock_server().await; let home = Arc::new(TempDir::new()?); let db = init_state_db(&home).await?; let memory_root = home.path().join("memories"); - let now = Utc::now(); + let now = chrono::Utc::now(); let _thread_a = seed_stage1_output( db.as_ref(), home.path(), - now - ChronoDuration::hours(2), + now - chrono::Duration::hours(2), "raw memory A", "rollout summary A", "rollout-a", @@ -61,7 +61,7 @@ async fn memories_startup_phase2_tracks_workspace_diff_across_runs() -> Result<( let _thread_b = seed_stage1_output( db.as_ref(), home.path(), - now - ChronoDuration::hours(1), + now - chrono::Duration::hours(1), "raw memory B", "rollout summary B", "rollout-b", @@ -78,7 +78,9 @@ async fn memories_startup_phase2_tracks_workspace_diff_across_runs() -> Result<( ) .await; - let codex = build_test_codex(&server, home.clone()).await?; + let test = build_test_codex(&server, home.clone()).await?; + trigger_memories_startup(&test).await; + let request = wait_for_single_request(&phase2).await; let prompt = phase2_prompt_text(&request); assert!( @@ -108,20 +110,20 @@ async fn memories_startup_phase2_tracks_workspace_diff_across_runs() -> Result<( .all(|summary| !summary.contains("rollout summary A")) ); - shutdown_test_codex(&codex).await?; + shutdown_test_codex(&test).await?; Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn memories_startup_phase2_prunes_old_extension_resources() -> Result<()> { +#[tokio::test] +async fn memories_startup_phase2_prunes_old_extension_resources() -> anyhow::Result<()> { let server = start_mock_server().await; let home = Arc::new(TempDir::new()?); let db = init_state_db(&home).await?; - let now = Utc::now(); + let now = chrono::Utc::now(); let _thread_id = seed_stage1_output( db.as_ref(), home.path(), - now - ChronoDuration::hours(1), + now - chrono::Duration::hours(1), "raw memory", "rollout summary", "rollout", @@ -136,15 +138,14 @@ async fn memories_startup_phase2_prunes_old_extension_resources() -> Result<()> "instructions", ) .await?; - let old_file_name = format!( + let old_file = chronicle_resources.join(format!( "{}-abcd-10min-old.md", - (now - ChronoDuration::days(8)).format("%Y-%m-%dT%H-%M-%S") - ); - let old_file = chronicle_resources.join(&old_file_name); + (now - chrono::Duration::days(8)).format("%Y-%m-%dT%H-%M-%S") + )); tokio::fs::write(&old_file, "old resource").await?; let recent_file = chronicle_resources.join(format!( "{}-abcd-10min-recent.md", - (now - ChronoDuration::days(6)).format("%Y-%m-%dT%H-%M-%S") + (now - chrono::Duration::days(6)).format("%Y-%m-%dT%H-%M-%S") )); tokio::fs::write(&recent_file, "recent resource").await?; @@ -158,10 +159,11 @@ async fn memories_startup_phase2_prunes_old_extension_resources() -> Result<()> ) .await; - let codex = build_test_codex(&server, home.clone()).await?; + let test = build_test_codex(&server, home.clone()).await?; + trigger_memories_startup(&test).await; + let request = wait_for_single_request(&phase2).await; let prompt = phase2_prompt_text(&request); - assert!( prompt.contains("phase2_workspace_diff.md"), "expected workspace diff file in prompt: {prompt}" @@ -178,20 +180,20 @@ async fn memories_startup_phase2_prunes_old_extension_resources() -> Result<()> "recent extension resource should be retained" ); - shutdown_test_codex(&codex).await?; + shutdown_test_codex(&test).await?; Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn memories_startup_phase2_prunes_old_extension_resources_without_stage1_input() -> Result<()> -{ +#[tokio::test] +async fn memories_startup_phase2_prunes_old_extension_resources_without_stage1_input() +-> anyhow::Result<()> { let server = start_mock_server().await; let home = Arc::new(TempDir::new()?); let db = init_state_db(&home).await?; db.enqueue_global_consolidation(/*input_watermark*/ 1) .await?; - let now = Utc::now(); + let now = chrono::Utc::now(); let chronicle_resources = home.path().join("memories/extensions/chronicle/resources"); tokio::fs::create_dir_all(&chronicle_resources).await?; tokio::fs::write( @@ -200,11 +202,10 @@ async fn memories_startup_phase2_prunes_old_extension_resources_without_stage1_i "instructions", ) .await?; - let old_file_name = format!( + let old_file = chronicle_resources.join(format!( "{}-abcd-10min-old.md", - (now - ChronoDuration::days(8)).format("%Y-%m-%dT%H-%M-%S") - ); - let old_file = chronicle_resources.join(&old_file_name); + (now - chrono::Duration::days(8)).format("%Y-%m-%dT%H-%M-%S") + )); tokio::fs::write(&old_file, "old resource").await?; let phase2 = mount_sse_once( @@ -217,52 +218,120 @@ async fn memories_startup_phase2_prunes_old_extension_resources_without_stage1_i ) .await; - let codex = build_test_codex(&server, home.clone()).await?; + let test = build_test_codex(&server, home.clone()).await?; + trigger_memories_startup(&test).await; + let request = wait_for_single_request(&phase2).await; let prompt = phase2_prompt_text(&request); - assert!( prompt.contains("phase2_workspace_diff.md"), "expected workspace diff file in prompt: {prompt}" ); + wait_for_file_removed(&old_file).await?; wait_for_phase2_workspace_reset(&home.path().join("memories")).await?; - shutdown_test_codex(&codex).await?; + shutdown_test_codex(&test).await?; Ok(()) } -async fn build_test_codex(server: &wiremock::MockServer, home: Arc) -> Result { - #[allow(clippy::expect_used)] - let mut builder = test_codex().with_home(home).with_config(|config| { - config - .features - .enable(Feature::Sqlite) - .expect("test config should allow feature update"); - config - .features - .enable(Feature::MemoryTool) - .expect("test config should allow feature update"); - config.memories.max_raw_memories_for_consolidation = 1; - }); - builder.build(server).await +#[tokio::test] +async fn memories_startup_phase1_uses_live_thread_service_tier() -> anyhow::Result<()> { + let server = start_mock_server().await; + let home = Arc::new(TempDir::new()?); + let test = build_test_codex(&server, home).await?; + assert_eq!(test.config.service_tier, None); + + test.codex + .submit(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + approvals_reviewer: None, + sandbox_policy: None, + permission_profile: None, + windows_sandbox_level: None, + model: None, + effort: None, + summary: None, + service_tier: Some(Some(ServiceTier::Fast)), + collaboration_mode: None, + personality: None, + }) + .await?; + + let config_snapshot = wait_for_service_tier(&test, Some(ServiceTier::Fast)).await?; + assert_eq!(config_snapshot.service_tier, Some(ServiceTier::Fast)); + + let context = crate::runtime::MemoryStartupContext::new( + Arc::clone(&test.thread_manager), + test.thread_manager.auth_manager(), + test.session_configured.session_id, + Arc::clone(&test.codex), + &test.config, + config_snapshot.session_source.clone(), + ); + let request_context = context + .stage_one_request_context( + &test.config, + test.config.model.as_deref().unwrap_or("gpt-5.4-mini"), + ReasoningEffort::Low, + ) + .await; + assert_eq!(request_context.service_tier, Some(ServiceTier::Fast)); + + shutdown_test_codex(&test).await?; + Ok(()) } -async fn init_state_db(home: &Arc) -> Result> { +async fn build_test_codex( + server: &wiremock::MockServer, + home: Arc, +) -> anyhow::Result { + test_codex() + .with_home(home) + .with_config(|config| { + config + .features + .enable(Feature::Sqlite) + .expect("test config should allow feature update"); + config.memories.max_raw_memories_for_consolidation = 1; + }) + .build(server) + .await +} + +async fn init_state_db(home: &Arc) -> anyhow::Result> { let db = codex_state::StateRuntime::init(home.path().to_path_buf(), "test-provider".into()).await?; db.mark_backfill_complete(/*last_watermark*/ None).await?; Ok(db) } +async fn trigger_memories_startup(test: &TestCodex) { + let config_snapshot = test.codex.config_snapshot().await; + let mut config = test.config.clone(); + config + .features + .enable(Feature::MemoryTool) + .expect("test config should allow feature update"); + start_memories_startup_task( + Arc::clone(&test.thread_manager), + test.thread_manager.auth_manager(), + test.session_configured.session_id, + Arc::clone(&test.codex), + Arc::new(config), + &config_snapshot.session_source, + ); +} + async fn seed_stage1_output( db: &codex_state::StateRuntime, codex_home: &Path, - updated_at: chrono::DateTime, + updated_at: chrono::DateTime, raw_memory: &str, rollout_summary: &str, rollout_slug: &str, -) -> Result { +) -> anyhow::Result { let thread_id = ThreadId::new(); let mut metadata_builder = codex_state::ThreadMetadataBuilder::new( thread_id, @@ -293,7 +362,7 @@ async fn wait_for_single_request(mock: &ResponseMock) -> ResponsesRequest { wait_for_request(mock, /*expected_count*/ 1).await.remove(0) } -async fn wait_for_file_removed(path: &Path) -> Result<()> { +async fn wait_for_file_removed(path: &Path) -> anyhow::Result<()> { let deadline = Instant::now() + Duration::from_secs(10); loop { if !tokio::fs::try_exists(path).await? { @@ -323,7 +392,25 @@ async fn wait_for_request(mock: &ResponseMock, expected_count: usize) -> Vec, +) -> anyhow::Result { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let config_snapshot = test.codex.config_snapshot().await; + if config_snapshot.service_tier == expected_service_tier { + return Ok(config_snapshot); + } + anyhow::ensure!( + Instant::now() < deadline, + "timed out waiting for service_tier to become {expected_service_tier:?}, current={:?}", + config_snapshot.service_tier + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + fn phase2_prompt_text(request: &ResponsesRequest) -> String { request .message_input_texts("user") @@ -332,7 +419,7 @@ fn phase2_prompt_text(request: &ResponsesRequest) -> String { .expect("phase2 prompt text") } -async fn wait_for_phase2_workspace_reset(memory_root: &Path) -> Result<()> { +async fn wait_for_phase2_workspace_reset(memory_root: &Path) -> anyhow::Result<()> { wait_for_file_removed(&memory_root.join("phase2_workspace_diff.md")).await?; let deadline = Instant::now() + Duration::from_secs(10); loop { @@ -356,7 +443,7 @@ async fn seed_stage1_output_for_existing_thread( raw_memory: &str, rollout_summary: &str, rollout_slug: Option<&str>, -) -> Result<()> { +) -> anyhow::Result<()> { let owner = ThreadId::new(); let claim = db .try_claim_stage1_job( @@ -385,7 +472,7 @@ async fn seed_stage1_output_for_existing_thread( Ok(()) } -async fn read_rollout_summary_bodies(memory_root: &Path) -> Result> { +async fn read_rollout_summary_bodies(memory_root: &Path) -> anyhow::Result> { let mut dir = tokio::fs::read_dir(memory_root.join("rollout_summaries")).await?; let mut summaries = Vec::new(); while let Some(entry) = dir.next_entry().await? { @@ -395,7 +482,7 @@ async fn read_rollout_summary_bodies(memory_root: &Path) -> Result> Ok(summaries) } -async fn shutdown_test_codex(test: &TestCodex) -> Result<()> { +async fn shutdown_test_codex(test: &TestCodex) -> anyhow::Result<()> { test.codex.submit(Op::Shutdown {}).await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await; Ok(()) diff --git a/codex-rs/memories/write/src/storage_tests.rs b/codex-rs/memories/write/src/storage_tests.rs index 28344c5814..95e57aa3f8 100644 --- a/codex-rs/memories/write/src/storage_tests.rs +++ b/codex-rs/memories/write/src/storage_tests.rs @@ -1,10 +1,18 @@ use super::rollout_summary_file_stem; +use crate::ensure_layout; +use crate::raw_memories_file; +use crate::rebuild_raw_memories_file_from_memories; +use crate::rollout_summaries_dir; +use crate::sync_rollout_summaries_from_memories; use chrono::TimeZone; use chrono::Utc; +use codex_config::types::DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION; use codex_protocol::ThreadId; use codex_state::Stage1Output; use pretty_assertions::assert_eq; use std::path::PathBuf; +use tempfile::tempdir; + const FIXED_PREFIX: &str = "2025-02-11T15-35-19-jqmb"; fn stage1_output_with_slug(thread_id: ThreadId, rollout_slug: Option<&str>) -> Stage1Output { @@ -59,3 +67,83 @@ fn rollout_summary_file_stem_uses_uuid_timestamp_and_hash_when_slug_is_empty() { assert_eq!(rollout_summary_file_stem(&memory), FIXED_PREFIX); } + +#[tokio::test] +async fn sync_rollout_summaries_and_raw_memories_file_keeps_latest_memories_only() { + let dir = tempdir().expect("tempdir"); + let root = dir.path().join("memory"); + ensure_layout(&root).await.expect("ensure layout"); + + let keep_id = ThreadId::default().to_string(); + let drop_id = ThreadId::default().to_string(); + let keep_path = rollout_summaries_dir(&root).join(format!("{keep_id}.md")); + let drop_path = rollout_summaries_dir(&root).join(format!("{drop_id}.md")); + tokio::fs::write(&keep_path, "keep") + .await + .expect("write keep"); + tokio::fs::write(&drop_path, "drop") + .await + .expect("write drop"); + + let memories = vec![Stage1Output { + thread_id: ThreadId::try_from(keep_id.clone()).expect("thread id"), + source_updated_at: Utc.timestamp_opt(100, 0).single().expect("timestamp"), + raw_memory: "raw memory".to_string(), + rollout_summary: "short summary".to_string(), + rollout_slug: None, + rollout_path: PathBuf::from("/tmp/rollout-100.jsonl"), + cwd: PathBuf::from("/tmp/workspace"), + git_branch: None, + generated_at: Utc.timestamp_opt(101, 0).single().expect("timestamp"), + }]; + + sync_rollout_summaries_from_memories( + &root, + &memories, + DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION, + ) + .await + .expect("sync rollout summaries"); + rebuild_raw_memories_file_from_memories( + &root, + &memories, + DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION, + ) + .await + .expect("rebuild raw memories"); + + assert!( + !tokio::fs::try_exists(&keep_path) + .await + .expect("check stale keep path"), + "sync should prune stale filename that used thread id only" + ); + assert!( + !tokio::fs::try_exists(&drop_path) + .await + .expect("check stale drop path"), + "sync should prune stale filename for dropped thread" + ); + + let mut dir = tokio::fs::read_dir(rollout_summaries_dir(&root)) + .await + .expect("open rollout summaries dir"); + let mut files = Vec::new(); + while let Some(entry) = dir.next_entry().await.expect("read dir entry") { + files.push(entry.file_name().to_string_lossy().to_string()); + } + files.sort_unstable(); + assert_eq!(files.len(), 1); + let canonical_rollout_summary_file = &files[0]; + + let raw_memories = tokio::fs::read_to_string(raw_memories_file(&root)) + .await + .expect("read raw memories"); + assert!(raw_memories.contains("raw memory")); + assert!(raw_memories.contains(&keep_id)); + assert!(raw_memories.contains("cwd: /tmp/workspace")); + assert!(raw_memories.contains("rollout_path: /tmp/rollout-100.jsonl")); + assert!(raw_memories.contains(&format!( + "rollout_summary_file: {canonical_rollout_summary_file}" + ))); +} diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index c731dfae3a..c39c017fe4 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -758,12 +758,6 @@ pub enum Op { /// to generate a summary which will be returned as an AgentMessage event. Compact, - /// Drop all persisted memory artifacts and memory-tracking DB rows. - DropMemories, - - /// Trigger a single pass of the startup memory pipeline. - UpdateMemories, - /// Set a user-facing thread name in the persisted rollout metadata. /// This is a local-only operation handled by codex-core; it does not /// involve the model. @@ -906,8 +900,6 @@ impl Op { Self::ReloadUserConfig => "reload_user_config", Self::ListSkills { .. } => "list_skills", Self::Compact => "compact", - Self::DropMemories => "drop_memories", - Self::UpdateMemories => "update_memories", Self::SetThreadName { .. } => "set_thread_name", Self::SetThreadMemoryMode { .. } => "set_thread_memory_mode", Self::Undo => "undo", @@ -2640,11 +2632,19 @@ pub enum SessionSource { Exec, Mcp, Custom(String), + Internal(InternalSessionSource), SubAgent(SubAgentSource), #[serde(other)] Unknown, } +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] +pub enum InternalSessionSource { + MemoryConsolidation, +} + #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] #[ts(rename_all = "snake_case")] @@ -2673,6 +2673,7 @@ impl fmt::Display for SessionSource { SessionSource::Exec => f.write_str("exec"), SessionSource::Mcp => f.write_str("mcp"), SessionSource::Custom(source) => f.write_str(source), + SessionSource::Internal(source) => write!(f, "internal_{source}"), SessionSource::SubAgent(sub_source) => write!(f, "subagent_{sub_source}"), SessionSource::Unknown => f.write_str("unknown"), } @@ -2701,19 +2702,28 @@ impl SessionSource { pub fn thread_source_name(&self) -> Option<&'static str> { match self { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), + SessionSource::Internal(_) => Some("internal"), SessionSource::SubAgent(_) => Some("subagent"), SessionSource::Mcp | SessionSource::Custom(_) | SessionSource::Unknown => None, } } + pub fn is_internal(&self) -> bool { + matches!(self, SessionSource::Internal(_)) + } + + pub fn is_non_root_agent(&self) -> bool { + matches!( + self, + SessionSource::Internal(_) | SessionSource::SubAgent(_) + ) + } + pub fn get_nickname(&self) -> Option { match self { SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_nickname, .. }) => { agent_nickname.clone() } - SessionSource::SubAgent(SubAgentSource::MemoryConsolidation) => { - Some("Morpheus".to_string()) - } _ => None, } } @@ -2723,9 +2733,6 @@ impl SessionSource { SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_role, .. }) => { agent_role.clone() } - SessionSource::SubAgent(SubAgentSource::MemoryConsolidation) => { - Some("memory builder".to_string()) - } _ => None, } } @@ -2735,9 +2742,6 @@ impl SessionSource { SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_path, .. }) => { agent_path.clone() } - SessionSource::SubAgent(SubAgentSource::MemoryConsolidation) => { - Some(AgentPath::morpheus()) - } _ => None, } } @@ -2750,7 +2754,7 @@ impl SessionSource { | SessionSource::Exec | SessionSource::Mcp | SessionSource::Unknown => Some(Product::Codex), - SessionSource::SubAgent(_) => None, + SessionSource::Internal(_) | SessionSource::SubAgent(_) => None, } } @@ -2780,6 +2784,14 @@ impl fmt::Display for SubAgentSource { } } +impl fmt::Display for InternalSessionSource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + InternalSessionSource::MemoryConsolidation => f.write_str("memory_consolidation"), + } + } +} + /// SessionMeta contains session-level data that doesn't correspond to a specific turn. /// /// NOTE: There used to be an `instructions` field here, which stored user_instructions, but we @@ -4101,6 +4113,10 @@ mod tests { (SessionSource::Cli, Some("user")), (SessionSource::VSCode, Some("user")), (SessionSource::Exec, Some("user")), + ( + SessionSource::Internal(InternalSessionSource::MemoryConsolidation), + Some("internal"), + ), ( SessionSource::SubAgent(SubAgentSource::Review), Some("subagent"), @@ -4143,6 +4159,11 @@ mod tests { SessionSource::SubAgent(SubAgentSource::Review).restriction_product(), None ); + assert_eq!( + SessionSource::Internal(InternalSessionSource::MemoryConsolidation) + .restriction_product(), + None + ); } #[test] diff --git a/codex-rs/thread-store/src/remote/helpers.rs b/codex-rs/thread-store/src/remote/helpers.rs index 5f5bf503a3..8b0a739225 100644 --- a/codex-rs/thread-store/src/remote/helpers.rs +++ b/codex-rs/thread-store/src/remote/helpers.rs @@ -124,6 +124,7 @@ pub(super) fn proto_session_source(source: &SessionSource) -> proto::SessionSour sub_agent_other: Some(other.clone()), ..Default::default() }, + SessionSource::Internal(_) => proto_source(proto::SessionSourceKind::Unknown), SessionSource::Unknown => proto_source(proto::SessionSourceKind::Unknown), } } From b7c0f269109f15331ccd8d4efd23377e5fe69aed Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 28 Apr 2026 13:06:41 +0200 Subject: [PATCH 06/53] feat: fix hinting 2 (#19961) Fix this: https://github.com/openai/codex/pull/19805#discussion_r3153265562 --- codex-rs/core/src/session/multi_agents.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/src/session/multi_agents.rs b/codex-rs/core/src/session/multi_agents.rs index c94f89602c..2820b5ff52 100644 --- a/codex-rs/core/src/session/multi_agents.rs +++ b/codex-rs/core/src/session/multi_agents.rs @@ -1,6 +1,7 @@ use crate::session::turn_context::TurnContext; use codex_features::Feature; use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; pub(super) fn usage_hint_text<'a>( turn_context: &'a TurnContext, @@ -12,7 +13,15 @@ pub(super) fn usage_hint_text<'a>( let multi_agent_v2 = &turn_context.config.multi_agent_v2; match session_source { - SessionSource::SubAgent(_) => multi_agent_v2.subagent_usage_hint_text.as_deref(), - _ => multi_agent_v2.root_agent_usage_hint_text.as_deref(), + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { .. }) => { + multi_agent_v2.subagent_usage_hint_text.as_deref() + } + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => multi_agent_v2.root_agent_usage_hint_text.as_deref(), + SessionSource::SubAgent(_) => None, } } From 54d14011708bf551a04c6996e4ce10c001185042 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 28 Apr 2026 13:12:51 +0200 Subject: [PATCH 07/53] feat: fix hinting 3 (#19963) Fix https://github.com/openai/codex/pull/19805#discussion_r3153265562 --- codex-rs/core/src/agent/control.rs | 1 + codex-rs/core/src/session/mod.rs | 32 ++++++++++++++-------------- codex-rs/core/src/session/tests.rs | 34 ++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 16 deletions(-) diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 25fc92206e..19ef2a54fe 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -394,6 +394,7 @@ impl AgentControl { if let Some(parent_thread) = parent_thread.as_ref() { parent_thread .codex + .session .configured_multi_agent_v2_usage_hint_texts() .await } else if config.features.enabled(Feature::MultiAgentV2) { diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 8e76c7feeb..34107e2f5d 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -764,22 +764,6 @@ impl Codex { state.session_configuration.thread_config_snapshot() } - pub(crate) async fn configured_multi_agent_v2_usage_hint_texts(&self) -> Vec { - let state = self.session.state.lock().await; - let config = &state.session_configuration.original_config_do_not_use; - if !config.features.enabled(Feature::MultiAgentV2) { - return Vec::new(); - } - - [ - config.multi_agent_v2.root_agent_usage_hint_text.clone(), - config.multi_agent_v2.subagent_usage_hint_text.clone(), - ] - .into_iter() - .flatten() - .collect() - } - pub(crate) fn state_db(&self) -> Option { self.session.state_db() } @@ -857,6 +841,22 @@ impl Session { } } + pub(crate) async fn configured_multi_agent_v2_usage_hint_texts(&self) -> Vec { + if !self.features.enabled(Feature::MultiAgentV2) { + return Vec::new(); + } + + let state = self.state.lock().await; + let config = &state.session_configuration.original_config_do_not_use; + [ + config.multi_agent_v2.root_agent_usage_hint_text.clone(), + config.multi_agent_v2.subagent_usage_hint_text.clone(), + ] + .into_iter() + .flatten() + .collect() + } + fn managed_network_proxy_active_for_permission_profile( permission_profile: &PermissionProfile, ) -> bool { diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index b6153e724d..731987faa9 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -5431,6 +5431,40 @@ async fn build_initial_context_omits_multi_agent_v2_usage_hints_when_feature_dis ); } +#[tokio::test] +async fn configured_multi_agent_v2_usage_hint_texts_use_effective_enabled_feature_state() { + let (mut session, _turn_context) = + make_multi_agent_v2_usage_hint_test_session(/*enable_multi_agent_v2*/ false).await; + let mut effective_features = Features::with_defaults(); + effective_features.enable(Feature::MultiAgentV2); + Arc::get_mut(&mut session) + .expect("session should not be shared") + .features = effective_features.into(); + + let hint_texts = session.configured_multi_agent_v2_usage_hint_texts().await; + + assert_eq!( + hint_texts, + vec![ + "Root guidance.".to_string(), + "Subagent guidance.".to_string() + ] + ); +} + +#[tokio::test] +async fn configured_multi_agent_v2_usage_hint_texts_omit_effectively_disabled_feature() { + let (mut session, _turn_context) = + make_multi_agent_v2_usage_hint_test_session(/*enable_multi_agent_v2*/ true).await; + Arc::get_mut(&mut session) + .expect("session should not be shared") + .features = Features::with_defaults().into(); + + let hint_texts = session.configured_multi_agent_v2_usage_hint_texts().await; + + assert_eq!(hint_texts, Vec::::new()); +} + #[tokio::test] async fn build_initial_context_omits_default_image_save_location_with_image_history() { let (session, turn_context) = make_session_and_context().await; From fa127be25ff547c950240c4bfe6c100c394880b2 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 28 Apr 2026 13:32:05 +0200 Subject: [PATCH 08/53] Stabilize memory Phase 2 input ordering (#19967) ## Why Phase 2 still needs to choose the most relevant stage-1 memory outputs by usage and recency, but exposing that ranking as the rendered `raw_memories.md` order creates unnecessary large diff. Usage-count or timestamp changes can reshuffle otherwise unchanged memories, making the workspace diff noisy and giving the consolidation prompt a misleading recency signal from file position. This fix will reduce token consumption ## What Changed - Keep the existing top-N Phase 2 selection ranking by `usage_count`, `last_usage`, `source_updated_at`, and `thread_id`. - Return the selected rows in stable ascending `thread_id` order before syncing Phase 2 filesystem inputs. - Update the memory README, raw memories header, and consolidation prompt so they describe the stable order and tell the prompt to use metadata and workspace diffs instead of file order as the recency signal. - Adjust the memory runtime tests to use deterministic thread IDs and assert the stable return order separately from the ranked selection semantics. ## Test Coverage - Existing memory runtime tests in `codex-rs/state/src/runtime/memories.rs` now cover the stable returned ordering for Phase 2 inputs. --------- Co-authored-by: Codex --- codex-rs/core/src/session/multi_agents.rs | 2 +- codex-rs/memories/README.md | 9 +- codex-rs/memories/write/src/storage.rs | 2 +- .../write/templates/memories/consolidation.md | 11 +- codex-rs/state/src/runtime/memories.rs | 117 +++++++++++------- 5 files changed, 85 insertions(+), 56 deletions(-) diff --git a/codex-rs/core/src/session/multi_agents.rs b/codex-rs/core/src/session/multi_agents.rs index 2820b5ff52..2cab13cecd 100644 --- a/codex-rs/core/src/session/multi_agents.rs +++ b/codex-rs/core/src/session/multi_agents.rs @@ -22,6 +22,6 @@ pub(super) fn usage_hint_text<'a>( | SessionSource::Mcp | SessionSource::Custom(_) | SessionSource::Unknown => multi_agent_v2.root_agent_usage_hint_text.as_deref(), - SessionSource::SubAgent(_) => None, + SessionSource::Internal(_) | SessionSource::SubAgent(_) => None, } } diff --git a/codex-rs/memories/README.md b/codex-rs/memories/README.md index dfea3969f7..9195e89ada 100644 --- a/codex-rs/memories/README.md +++ b/codex-rs/memories/README.md @@ -94,7 +94,7 @@ What it does: `last_usage` / `generated_at` - computes a completion watermark from the claimed watermark + newest input timestamps - syncs local memory artifacts under the memories root: - - `raw_memories.md` (merged raw memories, latest first) + - `raw_memories.md` (merged raw memories, stable ascending thread-id order) - `rollout_summaries/` (one summary file per selected rollout) - keeps the memories root itself as a git-baseline directory, initialized under `~/.codex/memories/.git` by `codex-git-utils` @@ -127,9 +127,10 @@ Selection and workspace-diff behavior: - Phase 1 upserts preserve the previous `selected_for_phase2` baseline until the next successful Phase 2 run rewrites it - Phase 2 loads only the current top-N selected stage-1 inputs, syncs - `rollout_summaries/` and `raw_memories.md` directly to that selection, then - lets the git-style workspace diff surface additions, modifications, and - deletions against the previous successful memory baseline + `rollout_summaries/` directly to that selection, renders `raw_memories.md` + in stable ascending thread-id order to avoid usage-rank churn, then lets the + git-style workspace diff surface additions, modifications, and deletions + against the previous successful memory baseline - when the selected input set is empty, stale `rollout_summaries/` files are removed and `raw_memories.md` is rewritten to the empty-input placeholder; consolidated outputs such as `MEMORY.md`, `memory_summary.md`, and `skills/` diff --git a/codex-rs/memories/write/src/storage.rs b/codex-rs/memories/write/src/storage.rs index 478c95a26a..e3e3395f94 100644 --- a/codex-rs/memories/write/src/storage.rs +++ b/codex-rs/memories/write/src/storage.rs @@ -54,7 +54,7 @@ async fn rebuild_raw_memories_file( return tokio::fs::write(raw_memories_file(root), body).await; } - body.push_str("Merged stage-1 raw memories (latest first):\n\n"); + body.push_str("Merged stage-1 raw memories (stable ascending thread-id order):\n\n"); for memory in retained { writeln!(body, "## Thread `{}`", memory.thread_id).map_err(raw_memories_format_error)?; writeln!( diff --git a/codex-rs/memories/write/templates/memories/consolidation.md b/codex-rs/memories/write/templates/memories/consolidation.md index 8ce97a4322..96e9b5ddce 100644 --- a/codex-rs/memories/write/templates/memories/consolidation.md +++ b/codex-rs/memories/write/templates/memories/consolidation.md @@ -120,11 +120,12 @@ Primary inputs (always read these, if exists): Under `{{ memory_root }}/`: - `raw_memories.md` - - mechanical merge of `raw_memories` from Phase 1; ordered latest-first. - - Use this recency ordering as a major heuristic when choosing what to promote, expand, or deprecate. - - Default scan order: top-to-bottom. In INCREMENTAL UPDATE mode, bias attention toward the newest - portion first, then expand to older entries with enough coverage to avoid missing important older - context. + - mechanical merge of selected `raw_memories` from Phase 1; ordered by stable ascending thread id. + - Do not treat file order as recency or importance; use `updated_at`, workspace diff context, + and rollout content when choosing what to promote, expand, or deprecate. + - Default scan order: top-to-bottom. In INCREMENTAL UPDATE mode, use the workspace diff to find + changed entries first, then expand to unchanged entries with enough coverage to avoid missing + important older context. - source of rollout-level metadata needed for MEMORY.md `### rollout_summary_files` annotations; you should be able to find `cwd`, `rollout_path`, and `updated_at` there. diff --git a/codex-rs/state/src/runtime/memories.rs b/codex-rs/state/src/runtime/memories.rs index ccabca2253..1ac5e202c4 100644 --- a/codex-rs/state/src/runtime/memories.rs +++ b/codex-rs/state/src/runtime/memories.rs @@ -335,9 +335,10 @@ WHERE thread_id IN ( /// `last_usage` is within `max_unused_days`, or whose /// `source_updated_at` is within that window when the memory has never /// been used - /// - eligible rows are ordered by `usage_count DESC`, + /// - eligible rows are ranked by `usage_count DESC`, /// `COALESCE(last_usage, source_updated_at) DESC`, `source_updated_at DESC`, /// `thread_id DESC` + /// - the selected top-N rows are returned in stable `thread_id ASC` order /// /// The returned rows are the complete Phase 2 filesystem input. Phase 2 /// syncs these rows directly; deletions are represented by the workspace @@ -355,30 +356,43 @@ WHERE thread_id IN ( let current_rows = sqlx::query( r#" SELECT - so.thread_id, - COALESCE(t.rollout_path, '') AS rollout_path, - so.source_updated_at, - so.raw_memory, - so.rollout_summary, - so.rollout_slug, - so.generated_at, - COALESCE(t.cwd, '') AS cwd, - t.git_branch AS git_branch -FROM stage1_outputs AS so -LEFT JOIN threads AS t - ON t.id = so.thread_id -WHERE t.memory_mode = 'enabled' - AND (length(trim(so.raw_memory)) > 0 OR length(trim(so.rollout_summary)) > 0) - AND ( - (so.last_usage IS NOT NULL AND so.last_usage >= ?) - OR (so.last_usage IS NULL AND so.source_updated_at >= ?) - ) -ORDER BY - COALESCE(so.usage_count, 0) DESC, - COALESCE(so.last_usage, so.source_updated_at) DESC, - so.source_updated_at DESC, - so.thread_id DESC -LIMIT ? + selected.thread_id, + selected.rollout_path, + selected.source_updated_at, + selected.raw_memory, + selected.rollout_summary, + selected.rollout_slug, + selected.generated_at, + selected.cwd, + selected.git_branch +FROM ( + SELECT + so.thread_id, + COALESCE(t.rollout_path, '') AS rollout_path, + so.source_updated_at, + so.raw_memory, + so.rollout_summary, + so.rollout_slug, + so.generated_at, + COALESCE(t.cwd, '') AS cwd, + t.git_branch AS git_branch + FROM stage1_outputs AS so + LEFT JOIN threads AS t + ON t.id = so.thread_id + WHERE t.memory_mode = 'enabled' + AND (length(trim(so.raw_memory)) > 0 OR length(trim(so.rollout_summary)) > 0) + AND ( + (so.last_usage IS NOT NULL AND so.last_usage >= ?) + OR (so.last_usage IS NULL AND so.source_updated_at >= ?) + ) + ORDER BY + COALESCE(so.usage_count, 0) DESC, + COALESCE(so.last_usage, so.source_updated_at) DESC, + so.source_updated_at DESC, + so.thread_id DESC + LIMIT ? +) AS selected +ORDER BY selected.thread_id ASC "#, ) .bind(cutoff) @@ -1260,6 +1274,10 @@ mod tests { use std::sync::Arc; use uuid::Uuid; + fn stable_thread_id(value: &str) -> ThreadId { + ThreadId::from_string(value).expect("thread id") + } + #[tokio::test] async fn stage1_claim_skips_when_up_to_date() { let codex_home = unique_temp_dir(); @@ -2829,9 +2847,9 @@ VALUES (?, ?, ?, ?, ?) .await .expect("initialize runtime"); - let thread_id_a = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("thread id"); - let thread_id_b = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("thread id"); - let thread_id_c = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("thread id"); + let thread_id_a = stable_thread_id("00000000-0000-4000-8000-000000000001"); + let thread_id_b = stable_thread_id("00000000-0000-4000-8000-000000000002"); + let thread_id_c = stable_thread_id("00000000-0000-4000-8000-000000000003"); let owner = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner id"); for (thread_id, workspace) in [ @@ -2918,12 +2936,21 @@ VALUES (?, ?, ?, ?, ?) .expect("load phase2 input selection"); assert_eq!(selection.len(), 2); - assert_eq!(selection[0].thread_id, thread_id_c); assert_eq!( - selection[0].rollout_path, + selection + .iter() + .map(|output| output.thread_id) + .collect::>(), + vec![thread_id_b, thread_id_c] + ); + let selected_c = selection + .iter() + .find(|output| output.thread_id == thread_id_c) + .expect("thread c should be selected"); + assert_eq!( + selected_c.rollout_path, codex_home.join(format!("rollout-{thread_id_c}.jsonl")) ); - assert_eq!(selection[1].thread_id, thread_id_b); let _ = tokio::fs::remove_dir_all(codex_home).await; } @@ -3235,10 +3262,10 @@ VALUES (?, ?, ?, ?, ?) .await .expect("initialize runtime"); - let thread_id_a = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("thread a"); - let thread_id_b = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("thread b"); - let thread_id_c = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("thread c"); - let thread_id_d = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("thread d"); + let thread_id_a = stable_thread_id("00000000-0000-4000-8000-000000000001"); + let thread_id_b = stable_thread_id("00000000-0000-4000-8000-000000000002"); + let thread_id_c = stable_thread_id("00000000-0000-4000-8000-000000000003"); + let thread_id_d = stable_thread_id("00000000-0000-4000-8000-000000000004"); let owner = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner id"); for (thread_id, workspace) in [ @@ -3365,7 +3392,7 @@ VALUES (?, ?, ?, ?, ?) .iter() .map(|output| output.thread_id) .collect::>(), - vec![thread_id_d, thread_id_c] + vec![thread_id_c, thread_id_d] ); let _ = tokio::fs::remove_dir_all(codex_home).await; @@ -3768,9 +3795,9 @@ VALUES (?, ?, ?, ?, ?) let now = Utc::now(); let owner = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner id"); - let thread_a = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("thread id a"); - let thread_b = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("thread id b"); - let thread_c = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("thread id c"); + let thread_a = stable_thread_id("00000000-0000-4000-8000-000000000001"); + let thread_b = stable_thread_id("00000000-0000-4000-8000-000000000002"); + let thread_c = stable_thread_id("00000000-0000-4000-8000-000000000003"); for (thread_id, workspace) in [ (thread_a, "workspace-a"), @@ -3840,7 +3867,7 @@ VALUES (?, ?, ?, ?, ?) } let selection = runtime - .get_phase2_input_selection(/*n*/ 3, /*max_unused_days*/ 30) + .get_phase2_input_selection(/*n*/ 1, /*max_unused_days*/ 30) .await .expect("load phase2 input selection"); @@ -3849,7 +3876,7 @@ VALUES (?, ?, ?, ?, ?) .iter() .map(|output| output.thread_id) .collect::>(), - vec![thread_b, thread_a, thread_c] + vec![thread_b] ); let _ = tokio::fs::remove_dir_all(codex_home).await; @@ -3864,9 +3891,9 @@ VALUES (?, ?, ?, ?, ?) let now = Utc::now(); let owner = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner id"); - let thread_a = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("thread id a"); - let thread_b = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("thread id b"); - let thread_c = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("thread id c"); + let thread_a = stable_thread_id("00000000-0000-4000-8000-000000000001"); + let thread_b = stable_thread_id("00000000-0000-4000-8000-000000000002"); + let thread_c = stable_thread_id("00000000-0000-4000-8000-000000000003"); for (thread_id, workspace) in [ (thread_a, "workspace-a"), @@ -3945,7 +3972,7 @@ VALUES (?, ?, ?, ?, ?) .iter() .map(|output| output.thread_id) .collect::>(), - vec![thread_c, thread_b] + vec![thread_b, thread_c] ); let _ = tokio::fs::remove_dir_all(codex_home).await; From a9e5c34083d4593b51d520f4d45f751ef9eee297 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 28 Apr 2026 16:23:13 +0200 Subject: [PATCH 09/53] feat: trigger memories from user turns with cooldown (#19970) ## Why Memory startup was tied to thread lifecycle events such as create, load, and fork. That can run memory work before a thread receives real user input, and it makes startup cost scale with thread management instead of actual turns. Moving the trigger to `thread/sendInput` keeps memory startup aligned with the first real user turn and lets it use the current thread config at turn time. The idea is to prevent ghost cost due to pre-warm triggered by the app Turn-based startup can also make global phase-2 consolidation easier to request repeatedly, so this adds a success cooldown and tightens the default startup scan window. ## What Changed - Start `codex_memories_write::start_memories_startup_task` after a non-empty `thread/sendInput` turn is submitted, instead of from thread create/load/fork paths: https://github.com/openai/codex/blob/d4a6885b7829e2fd2ec7a09355e4f75ebe1d1fe3/codex-rs/app-server/src/codex_message_processor.rs#L6477-L6487 - Expose `CodexThread::config()` so app-server can pass the live config into memory startup at turn time. - Add a six-hour successful-run cooldown for global phase-2 consolidation via `SkippedCooldown`: https://github.com/openai/codex/blob/d4a6885b7829e2fd2ec7a09355e4f75ebe1d1fe3/codex-rs/state/src/runtime/memories.rs#L963-L966 - Reduce memory startup defaults to at most 2 rollouts over 10 days: https://github.com/openai/codex/blob/d4a6885b7829e2fd2ec7a09355e4f75ebe1d1fe3/codex-rs/config/src/types.rs#L31-L34 ## Verification Updated the memory runtime coverage around phase-2 reclaim behavior, including `phase2_global_lock_respects_success_cooldown`. --------- Co-authored-by: Codex --- .../app-server/src/codex_message_processor.rs | 57 ++++++----------- codex-rs/config/src/types.rs | 4 +- codex-rs/core/src/codex_thread.rs | 5 ++ codex-rs/memories/write/src/phase2.rs | 3 + codex-rs/state/src/model/memories.rs | 2 + codex-rs/state/src/runtime/memories.rs | 64 ++++++++++++++++--- 6 files changed, 85 insertions(+), 50 deletions(-) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 519ea50fd1..13f5bb7eb3 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -542,7 +542,6 @@ pub(crate) enum ApiVersion { #[derive(Clone)] struct ListenerTaskContext { - auth_manager: Arc, thread_manager: Arc, thread_state_manager: ThreadStateManager, outgoing: Arc, @@ -2415,7 +2414,6 @@ impl CodexMessageProcessor { ); typesafe_overrides.ephemeral = ephemeral; let listener_task_context = ListenerTaskContext { - auth_manager: Arc::clone(&self.auth_manager), thread_manager: Arc::clone(&self.thread_manager), thread_state_manager: self.thread_state_manager.clone(), outgoing: Arc::clone(&self.outgoing), @@ -2615,7 +2613,6 @@ impl CodexMessageProcessor { .collect() }; let core_dynamic_tool_count = core_dynamic_tools.len(); - let memory_config = Arc::new(config.clone()); let NewThread { thread_id, @@ -2669,14 +2666,6 @@ impl CodexMessageProcessor { otel.name = "app_server.thread_start.config_snapshot", )) .await; - codex_memories_write::start_memories_startup_task( - Arc::clone(&listener_task_context.thread_manager), - Arc::clone(&listener_task_context.auth_manager), - thread_id, - Arc::clone(&thread), - Arc::clone(&memory_config), - &config_snapshot.session_source, - ); let mut thread = build_thread_from_snapshot( thread_id, &config_snapshot, @@ -4178,7 +4167,6 @@ impl CodexMessageProcessor { let instruction_sources = Self::instruction_sources_from_config(&config).await; let response_history = thread_history.clone(); - let memory_config = Arc::new(config.clone()); match self .thread_manager @@ -4253,14 +4241,6 @@ impl CodexMessageProcessor { /*has_live_in_progress_turn*/ false, ); let config_snapshot = codex_thread.config_snapshot().await; - codex_memories_write::start_memories_startup_task( - Arc::clone(&self.thread_manager), - Arc::clone(&self.auth_manager), - thread_id, - Arc::clone(&codex_thread), - Arc::clone(&memory_config), - &config_snapshot.session_source, - ); let sandbox = thread_response_sandbox_policy( &config_snapshot.permission_profile, config_snapshot.cwd.as_path(), @@ -4766,7 +4746,6 @@ impl CodexMessageProcessor { let fallback_model_provider = config.model_provider_id.clone(); let instruction_sources = Self::instruction_sources_from_config(&config).await; let fork_thread_store = configured_thread_store(&config); - let memory_config = Arc::new(config.clone()); let NewThread { thread_id, @@ -4862,14 +4841,6 @@ impl CodexMessageProcessor { /*has_in_progress_turn*/ false, ); let config_snapshot = forked_thread.config_snapshot().await; - codex_memories_write::start_memories_startup_task( - Arc::clone(&self.thread_manager), - Arc::clone(&self.auth_manager), - thread_id, - Arc::clone(&forked_thread), - Arc::clone(&memory_config), - &config_snapshot.session_source, - ); let sandbox = thread_response_sandbox_policy( &config_snapshot.permission_profile, config_snapshot.cwd.as_path(), @@ -6363,12 +6334,12 @@ impl CodexMessageProcessor { ); return Err(error); } - let (_, thread) = self - .load_thread(¶ms.thread_id) - .await - .inspect_err(|error| { - self.track_error_response(&request_id, error, /*error_type*/ None); - })?; + let (thread_id, thread) = + self.load_thread(¶ms.thread_id) + .await + .inspect_err(|error| { + self.track_error_response(&request_id, error, /*error_type*/ None); + })?; Self::set_app_server_client_info( thread.as_ref(), app_server_client_name, @@ -6408,6 +6379,7 @@ impl CodexMessageProcessor { .into_iter() .map(V2UserInput::into_core) .collect(); + let turn_has_input = !mapped_items.is_empty(); let has_any_overrides = params.cwd.is_some() || params.approval_policy.is_some() @@ -6502,6 +6474,18 @@ impl CodexMessageProcessor { error })?; + if turn_has_input { + let config_snapshot = thread.config_snapshot().await; + codex_memories_write::start_memories_startup_task( + Arc::clone(&self.thread_manager), + Arc::clone(&self.auth_manager), + thread_id, + Arc::clone(&thread), + thread.config().await, + &config_snapshot.session_source, + ); + } + self.outgoing .record_request_turn_id(&request_id, &turn_id) .await; @@ -7188,7 +7172,6 @@ impl CodexMessageProcessor { ) -> Result { Self::ensure_conversation_listener_task( ListenerTaskContext { - auth_manager: Arc::clone(&self.auth_manager), thread_manager: Arc::clone(&self.thread_manager), thread_state_manager: self.thread_state_manager.clone(), outgoing: Arc::clone(&self.outgoing), @@ -7306,7 +7289,6 @@ impl CodexMessageProcessor { ) -> Result<(), JSONRPCErrorError> { Self::ensure_listener_task_running_task( ListenerTaskContext { - auth_manager: Arc::clone(&self.auth_manager), thread_manager: Arc::clone(&self.thread_manager), thread_state_manager: self.thread_state_manager.clone(), outgoing: Arc::clone(&self.outgoing), @@ -7355,7 +7337,6 @@ impl CodexMessageProcessor { thread_state.set_listener(cancel_tx, &conversation) }; let ListenerTaskContext { - auth_manager: _, outgoing, thread_manager, thread_state_manager, diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index 1b9066d07f..f46a7fc009 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -29,8 +29,8 @@ use serde::Deserialize; use serde::Serialize; pub const DEFAULT_OTEL_ENVIRONMENT: &str = "dev"; -pub const DEFAULT_MEMORIES_MAX_ROLLOUTS_PER_STARTUP: usize = 16; -pub const DEFAULT_MEMORIES_MAX_ROLLOUT_AGE_DAYS: i64 = 30; +pub const DEFAULT_MEMORIES_MAX_ROLLOUTS_PER_STARTUP: usize = 2; +pub const DEFAULT_MEMORIES_MAX_ROLLOUT_AGE_DAYS: i64 = 10; pub const DEFAULT_MEMORIES_MIN_ROLLOUT_IDLE_HOURS: i64 = 6; pub const DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION: usize = 256; pub const DEFAULT_MEMORIES_MAX_UNUSED_DAYS: i64 = 30; diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index eb5afe57f6..47f1b99ce5 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -34,6 +34,7 @@ use codex_utils_absolute_path::AbsolutePathBuf; use rmcp::model::ReadResourceRequestParams; use std::collections::HashMap; use std::path::PathBuf; +use std::sync::Arc; use tokio::sync::Mutex; use tokio::sync::watch; @@ -376,6 +377,10 @@ impl CodexThread { self.codex.thread_config_snapshot().await } + pub async fn config(&self) -> Arc { + self.codex.session.get_config().await + } + pub async fn read_mcp_resource( &self, server: &str, diff --git a/codex-rs/memories/write/src/phase2.rs b/codex-rs/memories/write/src/phase2.rs index 7278207f34..2d092abfb2 100644 --- a/codex-rs/memories/write/src/phase2.rs +++ b/codex-rs/memories/write/src/phase2.rs @@ -244,6 +244,9 @@ mod job { codex_state::Phase2JobClaimOutcome::SkippedRetryUnavailable => { return Err("skipped_retry_unavailable"); } + codex_state::Phase2JobClaimOutcome::SkippedCooldown => { + return Err("skipped_cooldown"); + } codex_state::Phase2JobClaimOutcome::SkippedRunning => return Err("skipped_running"), }; diff --git a/codex-rs/state/src/model/memories.rs b/codex-rs/state/src/model/memories.rs index ada9d4e1e4..9bb34405ae 100644 --- a/codex-rs/state/src/model/memories.rs +++ b/codex-rs/state/src/model/memories.rs @@ -117,6 +117,8 @@ pub enum Phase2JobClaimOutcome { }, /// The global job is in retry backoff. SkippedRetryUnavailable, + /// The global job completed recently enough that consolidation is cooling down. + SkippedCooldown, /// Another worker currently owns a fresh global consolidation lease. SkippedRunning, } diff --git a/codex-rs/state/src/runtime/memories.rs b/codex-rs/state/src/runtime/memories.rs index 1ac5e202c4..5b75225b1e 100644 --- a/codex-rs/state/src/runtime/memories.rs +++ b/codex-rs/state/src/runtime/memories.rs @@ -19,6 +19,7 @@ use uuid::Uuid; const JOB_KIND_MEMORY_STAGE1: &str = "memory_stage1"; const JOB_KIND_MEMORY_CONSOLIDATE_GLOBAL: &str = "memory_consolidate_global"; const MEMORY_CONSOLIDATION_JOB_KEY: &str = "global"; +const PHASE2_SUCCESS_COOLDOWN_SECONDS: i64 = 6 * 60 * 60; const DEFAULT_RETRY_REMAINING: i64 = 3; @@ -873,6 +874,8 @@ WHERE kind = ? AND job_key = ? /// dirtiness is the source of truth after the caller materializes inputs /// - returns `SkippedRetryUnavailable` when retry backoff is active /// - returns `SkippedRunning` when an active running lease exists + /// - returns `SkippedCooldown` when the latest successful run finished + /// within the phase-2 success cooldown /// - otherwise updates the row to `running`, sets ownership + lease, and /// returns `Claimed` pub async fn try_claim_global_phase2_job( @@ -882,6 +885,7 @@ WHERE kind = ? AND job_key = ? ) -> anyhow::Result { let now = Utc::now().timestamp(); let lease_until = now.saturating_add(lease_seconds.max(0)); + let cooldown_cutoff = now.saturating_sub(PHASE2_SUCCESS_COOLDOWN_SECONDS); let ownership_token = Uuid::new_v4().to_string(); let worker_id = worker_id.to_string(); @@ -889,7 +893,7 @@ WHERE kind = ? AND job_key = ? let existing_job = sqlx::query( r#" -SELECT status, lease_until, retry_at, input_watermark +SELECT status, lease_until, retry_at, input_watermark, finished_at, last_error FROM jobs WHERE kind = ? AND job_key = ? "#, @@ -946,6 +950,8 @@ INSERT INTO jobs ( let status: String = existing_job.try_get("status")?; let existing_lease_until: Option = existing_job.try_get("lease_until")?; let retry_at: Option = existing_job.try_get("retry_at")?; + let finished_at: Option = existing_job.try_get("finished_at")?; + let last_error: Option = existing_job.try_get("last_error")?; if retry_at.is_some_and(|retry_at| retry_at > now) { tx.commit().await?; return Ok(Phase2JobClaimOutcome::SkippedRetryUnavailable); @@ -955,6 +961,12 @@ INSERT INTO jobs ( tx.commit().await?; return Ok(Phase2JobClaimOutcome::SkippedRunning); } + if last_error.is_none() + && finished_at.is_some_and(|finished_at| finished_at > cooldown_cutoff) + { + tx.commit().await?; + return Ok(Phase2JobClaimOutcome::SkippedCooldown); + } let rows_affected = sqlx::query( r#" @@ -971,6 +983,7 @@ SET WHERE kind = ? AND job_key = ? AND (status != 'running' OR lease_until IS NULL OR lease_until <= ?) AND (retry_at IS NULL OR retry_at <= ?) + AND (last_error IS NOT NULL OR finished_at IS NULL OR finished_at <= ?) "#, ) .bind(worker_id.as_str()) @@ -981,6 +994,7 @@ WHERE kind = ? AND job_key = ? .bind(MEMORY_CONSOLIDATION_JOB_KEY) .bind(now) .bind(now) + .bind(cooldown_cutoff) .execute(&mut *tx) .await? .rows_affected(); @@ -1260,6 +1274,8 @@ ON CONFLICT(kind, job_key) DO UPDATE SET mod tests { use super::JOB_KIND_MEMORY_CONSOLIDATE_GLOBAL; use super::JOB_KIND_MEMORY_STAGE1; + use super::MEMORY_CONSOLIDATION_JOB_KEY; + use super::PHASE2_SUCCESS_COOLDOWN_SECONDS; use super::StateRuntime; use super::test_support::test_thread_metadata; use super::test_support::unique_temp_dir; @@ -1278,6 +1294,16 @@ mod tests { ThreadId::from_string(value).expect("thread id") } + async fn age_phase2_success_beyond_cooldown(runtime: &StateRuntime) { + sqlx::query("UPDATE jobs SET finished_at = ? WHERE kind = ? AND job_key = ?") + .bind(Utc::now().timestamp() - PHASE2_SUCCESS_COOLDOWN_SECONDS - 1) + .bind(JOB_KIND_MEMORY_CONSOLIDATE_GLOBAL) + .bind(MEMORY_CONSOLIDATION_JOB_KEY) + .execute(runtime.pool.as_ref()) + .await + .expect("age phase2 success beyond cooldown"); + } + #[tokio::test] async fn stage1_claim_skips_when_up_to_date() { let codex_home = unique_temp_dir(); @@ -2363,6 +2389,7 @@ WHERE kind = 'memory_stage1' .expect("stage1 output count"); assert_eq!(output_row_count, 0); + age_phase2_success_beyond_cooldown(&runtime).await; let claim_phase2 = runtime .try_claim_global_phase2_job(owner, /*lease_seconds*/ 3600) .await @@ -2486,7 +2513,7 @@ WHERE kind = 'memory_stage1' } #[tokio::test] - async fn phase2_global_lock_can_be_reclaimed_after_success_without_new_watermark() { + async fn phase2_global_lock_respects_success_cooldown() { let codex_home = unique_temp_dir(); let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string()) .await @@ -2522,10 +2549,27 @@ WHERE kind = 'memory_stage1' .try_claim_global_phase2_job(owner, /*lease_seconds*/ 3600) .await .expect("claim phase2 after success"); - assert!( - matches!(claim_after_success, Phase2JobClaimOutcome::Claimed { .. }), - "the DB claim is only a lock; git workspace diff decides whether there is work" - ); + assert_eq!(claim_after_success, Phase2JobClaimOutcome::SkippedCooldown); + + runtime + .enqueue_global_consolidation(/*input_watermark*/ 101) + .await + .expect("enqueue global consolidation after success"); + let claim_after_enqueue = runtime + .try_claim_global_phase2_job(owner, /*lease_seconds*/ 3600) + .await + .expect("claim phase2 after enqueue"); + assert_eq!(claim_after_enqueue, Phase2JobClaimOutcome::SkippedCooldown); + + age_phase2_success_beyond_cooldown(&runtime).await; + let claim_after_cooldown = runtime + .try_claim_global_phase2_job(owner, /*lease_seconds*/ 3600) + .await + .expect("claim phase2 after cooldown"); + assert!(matches!( + claim_after_cooldown, + Phase2JobClaimOutcome::Claimed { .. } + )); let _ = tokio::fs::remove_dir_all(codex_home).await; } @@ -3127,6 +3171,7 @@ VALUES (?, ?, ?, ?, ?) "thread should transition to polluted" ); + age_phase2_success_beyond_cooldown(&runtime).await; let next_claim = runtime .try_claim_global_phase2_job(owner, /*lease_seconds*/ 3600) .await @@ -3495,6 +3540,7 @@ VALUES (?, ?, ?, ?, ?) "refreshed stage1 success should persist output" ); + age_phase2_success_beyond_cooldown(&runtime).await; let second_phase2_claim = runtime .try_claim_global_phase2_job(owner, /*lease_seconds*/ 3600) .await @@ -4457,10 +4503,7 @@ VALUES (?, ?, ?, ?, ?) .try_claim_global_phase2_job(owner_b, /*lease_seconds*/ 3_600) .await .expect("claim global phase2 lock after success"); - assert!( - matches!(claim_after_success, Phase2JobClaimOutcome::Claimed { .. }), - "git workspace diff, not the DB watermark, decides whether the claimed lock has work" - ); + assert_eq!(claim_after_success, Phase2JobClaimOutcome::SkippedCooldown); let _ = tokio::fs::remove_dir_all(codex_home).await; } @@ -4583,6 +4626,7 @@ VALUES (?, ?, ?, ?, ?) .await .expect("enqueue lower-watermark consolidation"); + age_phase2_success_beyond_cooldown(&runtime).await; let owner_b = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner b"); let claim_b = runtime .try_claim_global_phase2_job(owner_b, /*lease_seconds*/ 3_600) From 0e8d6b87653aa37958cdc3e24d0681341b2f61d9 Mon Sep 17 00:00:00 2001 From: efrazer-oai Date: Tue, 28 Apr 2026 08:06:45 -0700 Subject: [PATCH 10/53] fix: configure AgentIdentity AuthAPI base URL (#19904) ## Summary AgentIdentity runtime loading currently registers tasks against a single hardcoded AuthAPI base URL. That works for production, but local and staging validation may need registration to target a different authapi-login-provider without baking internal staging service URLs into the OSS binary. This PR adds a small config surface for `agent_identity_authapi_base_url` and threads it through the existing auth-loading path as a direct argument. Explicit config wins. Without config, task registration keeps using the production AuthAPI URL, matching the current default behavior. ## Stack 1. openai/codex#19762 - `refactor: make auth loading async` (merged) 2. openai/codex#19763 - `refactor: load agent identity runtime eagerly` 3. This PR - `fix: configure AgentIdentity AuthAPI base URL` 4. openai/codex#19764 - `feat: verify agent identity JWTs with JWKS` ## Design decisions - Keep the existing auth-loading shape and pass the new value as an argument. This avoids another wrapper loader and keeps the call path readable. - Add config instead of embedding internal staging URLs. Environments that need a non-production AuthAPI can configure it explicitly. - Keep the default AuthAPI registration URL as production. `chatgpt_base_url` remains separate and is used by the follow-up JWKS verification PR for fetching public keys from the ChatGPT backend route. - Resolve the AuthAPI base URL inside AgentIdentity loading, because task registration is the only consumer of this value. ## Testing Tests: targeted Rust checks, AgentIdentity auth tests, config schema regeneration, formatter/fix pass, and whitespace diff check. --- codex-rs/login/src/auth/agent_identity.rs | 78 ++++++++++++++++++++++- 1 file changed, 76 insertions(+), 2 deletions(-) diff --git a/codex-rs/login/src/auth/agent_identity.rs b/codex-rs/login/src/auth/agent_identity.rs index 116bc09939..fd54174284 100644 --- a/codex-rs/login/src/auth/agent_identity.rs +++ b/codex-rs/login/src/auth/agent_identity.rs @@ -1,12 +1,14 @@ use codex_agent_identity::AgentIdentityKey; use codex_agent_identity::register_agent_task; use codex_protocol::account::PlanType as AccountPlanType; +use std::env; use crate::default_client::build_reqwest_client; use super::storage::AgentIdentityAuthRecord; -const AGENT_IDENTITY_AUTHAPI_BASE_URL: &str = "https://auth.openai.com/api/accounts"; +const PROD_AGENT_IDENTITY_AUTHAPI_BASE_URL: &str = "https://auth.openai.com/api/accounts"; +const CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL_ENV_VAR: &str = "CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL"; #[derive(Clone, Debug)] pub struct AgentIdentityAuth { @@ -16,9 +18,10 @@ pub struct AgentIdentityAuth { impl AgentIdentityAuth { pub async fn load(record: AgentIdentityAuthRecord) -> std::io::Result { + let agent_identity_authapi_base_url = agent_identity_authapi_base_url(); let process_task_id = register_agent_task( &build_reqwest_client(), - AGENT_IDENTITY_AUTHAPI_BASE_URL, + &agent_identity_authapi_base_url, key(&record), ) .await @@ -58,9 +61,80 @@ impl AgentIdentityAuth { } } +fn agent_identity_authapi_base_url() -> String { + env::var(CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL_ENV_VAR) + .ok() + .map(|base_url| base_url.trim().trim_end_matches('/').to_string()) + .filter(|base_url| !base_url.is_empty()) + .unwrap_or_else(|| PROD_AGENT_IDENTITY_AUTHAPI_BASE_URL.to_string()) +} + fn key(record: &AgentIdentityAuthRecord) -> AgentIdentityKey<'_> { AgentIdentityKey { agent_runtime_id: &record.agent_runtime_id, private_key_pkcs8_base64: &record.agent_private_key, } } + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + + #[test] + #[serial(agent_identity_authapi_base_url_env)] + fn agent_identity_authapi_base_url_prefers_env_value() { + let _guard = EnvVarGuard::set( + CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL_ENV_VAR, + "https://authapi.example.test/api/accounts/", + ); + assert_eq!( + agent_identity_authapi_base_url(), + "https://authapi.example.test/api/accounts" + ); + } + + #[test] + #[serial(agent_identity_authapi_base_url_env)] + fn agent_identity_authapi_base_url_uses_prod_authapi_by_default() { + let _guard = EnvVarGuard::remove(CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL_ENV_VAR); + assert_eq!( + agent_identity_authapi_base_url(), + PROD_AGENT_IDENTITY_AUTHAPI_BASE_URL + ); + } + + struct EnvVarGuard { + key: &'static str, + original: Option, + } + + impl EnvVarGuard { + fn set(key: &'static str, value: &str) -> Self { + let original = env::var_os(key); + unsafe { + env::set_var(key, value); + } + Self { key, original } + } + + fn remove(key: &'static str) -> Self { + let original = env::var_os(key); + unsafe { + env::remove_var(key); + } + Self { key, original } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + unsafe { + match &self.original { + Some(value) => env::set_var(self.key, value), + None => env::remove_var(self.key), + } + } + } + } +} From 1b743603651db33895b30345e47b7babe8a819a3 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 28 Apr 2026 17:07:16 +0200 Subject: [PATCH 11/53] feat: skip memory startup when Codex rate limits are low (#19990) ## Why Memory startup runs in the background after an eligible turn, but it can consume Codex backend quota at exactly the wrong time: when the user is already near a rate-limit boundary. This PR adds a guard so the memory pipeline backs off when the Codex rate-limit snapshot says the remaining budget is too low. ## What Changed - Added `memories.min_rate_limit_remaining_percent` with a default of `25`, clamped to `0..=100`, and regenerated `core/config.schema.json`. - Added `codex-rs/memories/write/src/guard.rs`, which fetches Codex backend rate limits before memory startup and skips phase 1 / phase 2 when the Codex limit is reached or either tracked window is above the configured usage ceiling. - Keeps startup best-effort: non-Codex auth or rate-limit fetch/client failures preserve the existing memory startup behavior. - Records a `codex.memory.startup` counter with `status=skipped_rate_limit` when startup is skipped. - Added config parsing/clamping coverage and guard unit tests. ## Verification - Added `codex-rs/memories/write/src/guard_tests.rs` for threshold, primary/secondary window, and reached-limit behavior. - Added config tests for TOML parsing and clamping. --- codex-rs/Cargo.lock | 1 + codex-rs/config/src/types.rs | 10 +++ codex-rs/config/src/types_tests.rs | 27 ++++++++ codex-rs/core/config.schema.json | 7 ++ codex-rs/core/src/config/config_tests.rs | 3 + codex-rs/memories/write/Cargo.toml | 1 + codex-rs/memories/write/src/guard.rs | 70 +++++++++++++++++++ codex-rs/memories/write/src/guard_tests.rs | 78 ++++++++++++++++++++++ codex-rs/memories/write/src/lib.rs | 1 + codex-rs/memories/write/src/start.rs | 18 ++++- 10 files changed, 214 insertions(+), 2 deletions(-) create mode 100644 codex-rs/memories/write/src/guard.rs create mode 100644 codex-rs/memories/write/src/guard_tests.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 164e8a0857..e3f2ac1a1f 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2957,6 +2957,7 @@ version = "0.0.0" dependencies = [ "anyhow", "chrono", + "codex-backend-client", "codex-config", "codex-core", "codex-features", diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index f46a7fc009..b1adc1fd73 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -32,6 +32,7 @@ pub const DEFAULT_OTEL_ENVIRONMENT: &str = "dev"; pub const DEFAULT_MEMORIES_MAX_ROLLOUTS_PER_STARTUP: usize = 2; pub const DEFAULT_MEMORIES_MAX_ROLLOUT_AGE_DAYS: i64 = 10; pub const DEFAULT_MEMORIES_MIN_ROLLOUT_IDLE_HOURS: i64 = 6; +pub const DEFAULT_MEMORIES_MIN_RATE_LIMIT_REMAINING_PERCENT: i64 = 25; pub const DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION: usize = 256; pub const DEFAULT_MEMORIES_MAX_UNUSED_DAYS: i64 = 30; const MIN_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION: usize = 1; @@ -204,6 +205,9 @@ pub struct MemoriesToml { pub max_rollouts_per_startup: Option, /// Minimum idle time between last thread activity and memory creation (hours). > 12h recommended. pub min_rollout_idle_hours: Option, + /// Minimum remaining percentage required in Codex rate-limit windows before memory startup runs. + #[schemars(range(min = 0, max = 100))] + pub min_rate_limit_remaining_percent: Option, /// Model used for thread summarisation. pub extract_model: Option, /// Model used for memory consolidation. @@ -221,6 +225,7 @@ pub struct MemoriesConfig { pub max_rollout_age_days: i64, pub max_rollouts_per_startup: usize, pub min_rollout_idle_hours: i64, + pub min_rate_limit_remaining_percent: i64, pub extract_model: Option, pub consolidation_model: Option, } @@ -236,6 +241,7 @@ impl Default for MemoriesConfig { max_rollout_age_days: DEFAULT_MEMORIES_MAX_ROLLOUT_AGE_DAYS, max_rollouts_per_startup: DEFAULT_MEMORIES_MAX_ROLLOUTS_PER_STARTUP, min_rollout_idle_hours: DEFAULT_MEMORIES_MIN_ROLLOUT_IDLE_HOURS, + min_rate_limit_remaining_percent: DEFAULT_MEMORIES_MIN_RATE_LIMIT_REMAINING_PERCENT, extract_model: None, consolidation_model: None, } @@ -277,6 +283,10 @@ impl From for MemoriesConfig { .min_rollout_idle_hours .unwrap_or(defaults.min_rollout_idle_hours) .clamp(1, 48), + min_rate_limit_remaining_percent: toml + .min_rate_limit_remaining_percent + .unwrap_or(defaults.min_rate_limit_remaining_percent) + .clamp(0, 100), extract_model: toml.extract_model, consolidation_model: toml.consolidation_model, } diff --git a/codex-rs/config/src/types_tests.rs b/codex-rs/config/src/types_tests.rs index b18c1cc645..2c3f69d986 100644 --- a/codex-rs/config/src/types_tests.rs +++ b/codex-rs/config/src/types_tests.rs @@ -59,3 +59,30 @@ fn memories_config_clamps_count_limits_to_nonzero_values() { } ); } + +#[test] +fn memories_config_clamps_rate_limit_remaining_threshold() { + let config = MemoriesConfig::from(MemoriesToml { + min_rate_limit_remaining_percent: Some(101), + ..Default::default() + }); + assert_eq!( + config, + MemoriesConfig { + min_rate_limit_remaining_percent: 100, + ..MemoriesConfig::default() + } + ); + + let config = MemoriesConfig::from(MemoriesToml { + min_rate_limit_remaining_percent: Some(-1), + ..Default::default() + }); + assert_eq!( + config, + MemoriesConfig { + min_rate_limit_remaining_percent: 0, + ..MemoriesConfig::default() + } + ); +} diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index c4a72c90b0..9cb23a4434 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1114,6 +1114,13 @@ "format": "int64", "type": "integer" }, + "min_rate_limit_remaining_percent": { + "description": "Minimum remaining percentage required in Codex rate-limit windows before memory startup runs.", + "format": "int64", + "maximum": 100.0, + "minimum": 0.0, + "type": "integer" + }, "min_rollout_idle_hours": { "description": "Minimum idle time between last thread activity and memory creation (hours). > 12h recommended.", "format": "int64", diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index a2b5312103..0e51ac37e9 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -266,6 +266,7 @@ max_unused_days = 21 max_rollout_age_days = 42 max_rollouts_per_startup = 9 min_rollout_idle_hours = 24 +min_rate_limit_remaining_percent = 12 extract_model = "gpt-5-mini" consolidation_model = "gpt-5.2" "#; @@ -281,6 +282,7 @@ consolidation_model = "gpt-5.2" max_rollout_age_days: Some(42), max_rollouts_per_startup: Some(9), min_rollout_idle_hours: Some(24), + min_rate_limit_remaining_percent: Some(12), extract_model: Some("gpt-5-mini".to_string()), consolidation_model: Some("gpt-5.2".to_string()), }), @@ -305,6 +307,7 @@ consolidation_model = "gpt-5.2" max_rollout_age_days: 42, max_rollouts_per_startup: 9, min_rollout_idle_hours: 24, + min_rate_limit_remaining_percent: 12, extract_model: Some("gpt-5-mini".to_string()), consolidation_model: Some("gpt-5.2".to_string()), } diff --git a/codex-rs/memories/write/Cargo.toml b/codex-rs/memories/write/Cargo.toml index 86ed831295..53d8705964 100644 --- a/codex-rs/memories/write/Cargo.toml +++ b/codex-rs/memories/write/Cargo.toml @@ -14,6 +14,7 @@ workspace = true [dependencies] anyhow = { workspace = true } chrono = { workspace = true } +codex-backend-client = { workspace = true } codex-core = { workspace = true } codex-config = { workspace = true } codex-features = { workspace = true } diff --git a/codex-rs/memories/write/src/guard.rs b/codex-rs/memories/write/src/guard.rs new file mode 100644 index 0000000000..7deb745170 --- /dev/null +++ b/codex-rs/memories/write/src/guard.rs @@ -0,0 +1,70 @@ +use codex_backend_client::Client as BackendClient; +use codex_core::config::Config; +use codex_login::AuthManager; +use codex_protocol::protocol::RateLimitSnapshot; +use codex_protocol::protocol::RateLimitWindow; +use tracing::info; +use tracing::warn; + +const CODEX_LIMIT_ID: &str = "codex"; + +pub(crate) async fn rate_limits_ok(auth_manager: &AuthManager, config: &Config) -> bool { + rate_limits_check(auth_manager, config) + .await + .unwrap_or(true) +} + +async fn rate_limits_check(auth_manager: &AuthManager, config: &Config) -> Option { + let auth = auth_manager.auth().await?; + if !auth.uses_codex_backend() { + return None; + } + + let client = BackendClient::from_auth(config.chatgpt_base_url.clone(), &auth) + .map_err(|err| warn!(%err, "failed to construct backend client")) + .ok()?; + + let snapshots = client + .get_rate_limits_many() + .await + .map_err(|err| warn!(%err, "failed to fetch rate limits")) + .ok()?; + + let snapshot = snapshots + .iter() + .find(|s| s.limit_id.as_deref() == Some(CODEX_LIMIT_ID)) + .or_else(|| snapshots.first())?; + + let min_remaining_percent = config.memories.min_rate_limit_remaining_percent; + let allowed = snapshot_allows_startup(snapshot, min_remaining_percent); + + if !allowed { + info!( + min_remaining_percent, + "skipping memories startup because Codex rate limits are below the configured threshold" + ); + } + + Some(allowed) +} + +fn snapshot_allows_startup(snapshot: &RateLimitSnapshot, min_remaining_percent: i64) -> bool { + if snapshot.rate_limit_reached_type.is_some() { + return false; + } + + let max_used_percent = 100.0 - min_remaining_percent.clamp(0, 100) as f64; + window_allows_startup(snapshot.primary.as_ref(), max_used_percent) + && window_allows_startup(snapshot.secondary.as_ref(), max_used_percent) +} + +fn window_allows_startup(window: Option<&RateLimitWindow>, max_used_percent: f64) -> bool { + match window { + Some(window) => window.used_percent <= max_used_percent, + None => true, + } +} + +#[cfg(test)] +#[path = "guard_tests.rs"] +mod tests; diff --git a/codex-rs/memories/write/src/guard_tests.rs b/codex-rs/memories/write/src/guard_tests.rs new file mode 100644 index 0000000000..6c22b0681c --- /dev/null +++ b/codex-rs/memories/write/src/guard_tests.rs @@ -0,0 +1,78 @@ +use super::*; +use codex_protocol::protocol::RateLimitReachedType; + +fn snapshot( + primary_used_percent: Option, + secondary_used_percent: Option, +) -> RateLimitSnapshot { + RateLimitSnapshot { + limit_id: Some(CODEX_LIMIT_ID.to_string()), + limit_name: None, + primary: primary_used_percent.map(window), + secondary: secondary_used_percent.map(window), + credits: None, + plan_type: None, + rate_limit_reached_type: None, + } +} + +fn window(used_percent: f64) -> RateLimitWindow { + RateLimitWindow { + used_percent, + window_minutes: None, + resets_at: None, + } +} + +#[test] +fn startup_check_uses_configured_remaining_threshold() { + let snapshot = snapshot( + /*primary_used_percent*/ Some(89.9), + /*secondary_used_percent*/ Some(50.0), + ); + + assert!(snapshot_allows_startup( + &snapshot, /*min_remaining_percent*/ 10 + )); + assert!(!snapshot_allows_startup( + &snapshot, /*min_remaining_percent*/ 11 + )); +} + +#[test] +fn startup_check_skips_when_primary_or_secondary_is_too_low() { + assert!(!snapshot_allows_startup( + &snapshot( + /*primary_used_percent*/ Some(75.1), + /*secondary_used_percent*/ Some(10.0), + ), + /*min_remaining_percent*/ 25, + )); + assert!(!snapshot_allows_startup( + &snapshot( + /*primary_used_percent*/ Some(10.0), + /*secondary_used_percent*/ Some(75.1), + ), + /*min_remaining_percent*/ 25, + )); + assert!(snapshot_allows_startup( + &snapshot( + /*primary_used_percent*/ Some(74.9), + /*secondary_used_percent*/ Some(74.9), + ), + /*min_remaining_percent*/ 25, + )); +} + +#[test] +fn startup_check_skips_when_limit_is_reached() { + let mut snapshot = snapshot( + /*primary_used_percent*/ Some(10.0), + /*secondary_used_percent*/ Some(10.0), + ); + snapshot.rate_limit_reached_type = Some(RateLimitReachedType::RateLimitReached); + + assert!(!snapshot_allows_startup( + &snapshot, /*min_remaining_percent*/ 25, + )); +} diff --git a/codex-rs/memories/write/src/lib.rs b/codex-rs/memories/write/src/lib.rs index be79663037..c92d5b6524 100644 --- a/codex-rs/memories/write/src/lib.rs +++ b/codex-rs/memories/write/src/lib.rs @@ -6,6 +6,7 @@ mod control; mod extensions; +mod guard; mod phase1; mod phase2; mod prompts; diff --git a/codex-rs/memories/write/src/start.rs b/codex-rs/memories/write/src/start.rs index 362ce9fa43..0ad8b27301 100644 --- a/codex-rs/memories/write/src/start.rs +++ b/codex-rs/memories/write/src/start.rs @@ -1,3 +1,4 @@ +use crate::guard; use crate::phase1; use crate::phase2; use crate::runtime::MemoryStartupContext; @@ -11,6 +12,8 @@ use codex_protocol::protocol::SessionSource; use std::sync::Arc; use tracing::warn; +const MEMORY_STARTUP: &str = "codex.memory.startup"; + /// Starts the asynchronous startup memory pipeline for an eligible root session. /// /// The pipeline is skipped for ephemeral sessions, disabled feature flags, and @@ -32,7 +35,7 @@ pub fn start_memories_startup_task( let context = Arc::new(MemoryStartupContext::new( thread_manager, - auth_manager, + Arc::clone(&auth_manager), thread_id, thread, config.as_ref(), @@ -45,8 +48,19 @@ pub fn start_memories_startup_task( } tokio::spawn(async move { - // Clean memories to make preserve DB size + // Clean memories to make preserve DB size. This does not consume tokens so can be + // done before the quota check. phase1::prune(context.as_ref(), &config).await; + + if !guard::rate_limits_ok(&auth_manager, &config).await { + context.counter( + MEMORY_STARTUP, + /*inc*/ 1, + &[("status", "skipped_rate_limit")], + ); + return; + } + // Run phase 1. phase1::run(Arc::clone(&context), Arc::clone(&config)).await; // Run phase 2. From 5a79dfab7c677cbec43fb1ea53e27c91be3091b3 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 28 Apr 2026 17:11:49 +0200 Subject: [PATCH 12/53] feat: house-keeping memories 1 (#19998) Just move metrics in a dedicated file --- codex-rs/memories/read/src/lib.rs | 1 + codex-rs/memories/read/src/metrics.rs | 1 + codex-rs/memories/read/src/usage.rs | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 codex-rs/memories/read/src/metrics.rs diff --git a/codex-rs/memories/read/src/lib.rs b/codex-rs/memories/read/src/lib.rs index e085684081..2f7b7645e4 100644 --- a/codex-rs/memories/read/src/lib.rs +++ b/codex-rs/memories/read/src/lib.rs @@ -5,6 +5,7 @@ //! not depend on the memory write pipeline. pub mod citations; +mod metrics; mod prompts; pub mod usage; diff --git a/codex-rs/memories/read/src/metrics.rs b/codex-rs/memories/read/src/metrics.rs new file mode 100644 index 0000000000..1e3645104e --- /dev/null +++ b/codex-rs/memories/read/src/metrics.rs @@ -0,0 +1 @@ +pub const MEMORIES_USAGE_METRIC: &str = "codex.memories.usage"; diff --git a/codex-rs/memories/read/src/usage.rs b/codex-rs/memories/read/src/usage.rs index 807bb2ba4f..277fb800d8 100644 --- a/codex-rs/memories/read/src/usage.rs +++ b/codex-rs/memories/read/src/usage.rs @@ -2,7 +2,7 @@ use codex_protocol::parse_command::ParsedCommand; use codex_shell_command::is_safe_command::is_known_safe_command; use codex_shell_command::parse_command::parse_command; -pub const MEMORIES_USAGE_METRIC: &str = "codex.memories.usage"; +pub use crate::metrics::MEMORIES_USAGE_METRIC; #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] pub enum MemoriesUsageKind { From 21e19912e0cd4f030b1c29365672d97b85dbc361 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 28 Apr 2026 17:26:44 +0200 Subject: [PATCH 13/53] feat: house-keeping memories 2 (#20000) Just move metrics in a dedicated file --- codex-rs/memories/write/src/lib.rs | 1 + codex-rs/memories/write/src/metrics.rs | 11 +++++++++++ codex-rs/memories/write/src/phase1.rs | 8 ++++---- codex-rs/memories/write/src/phase2.rs | 8 ++++---- codex-rs/memories/write/src/start.rs | 3 +-- 5 files changed, 21 insertions(+), 10 deletions(-) create mode 100644 codex-rs/memories/write/src/metrics.rs diff --git a/codex-rs/memories/write/src/lib.rs b/codex-rs/memories/write/src/lib.rs index c92d5b6524..63f1565710 100644 --- a/codex-rs/memories/write/src/lib.rs +++ b/codex-rs/memories/write/src/lib.rs @@ -7,6 +7,7 @@ mod control; mod extensions; mod guard; +mod metrics; mod phase1; mod phase2; mod prompts; diff --git a/codex-rs/memories/write/src/metrics.rs b/codex-rs/memories/write/src/metrics.rs new file mode 100644 index 0000000000..caa77565db --- /dev/null +++ b/codex-rs/memories/write/src/metrics.rs @@ -0,0 +1,11 @@ +pub(crate) const MEMORY_STARTUP: &str = "codex.memory.startup"; + +pub(crate) const MEMORY_PHASE_ONE_JOBS: &str = "codex.memory.phase1"; +pub(crate) const MEMORY_PHASE_ONE_E2E_MS: &str = "codex.memory.phase1.e2e_ms"; +pub(crate) const MEMORY_PHASE_ONE_OUTPUT: &str = "codex.memory.phase1.output"; +pub(crate) const MEMORY_PHASE_ONE_TOKEN_USAGE: &str = "codex.memory.phase1.token_usage"; + +pub(crate) const MEMORY_PHASE_TWO_JOBS: &str = "codex.memory.phase2"; +pub(crate) const MEMORY_PHASE_TWO_E2E_MS: &str = "codex.memory.phase2.e2e_ms"; +pub(crate) const MEMORY_PHASE_TWO_INPUT: &str = "codex.memory.phase2.input"; +pub(crate) const MEMORY_PHASE_TWO_TOKEN_USAGE: &str = "codex.memory.phase2.token_usage"; diff --git a/codex-rs/memories/write/src/phase1.rs b/codex-rs/memories/write/src/phase1.rs index 120ff870b1..a3a85b8e8b 100644 --- a/codex-rs/memories/write/src/phase1.rs +++ b/codex-rs/memories/write/src/phase1.rs @@ -1,5 +1,9 @@ use crate::STAGE_ONE_PROMPT; use crate::build_stage_one_input_message; +use crate::metrics::MEMORY_PHASE_ONE_E2E_MS; +use crate::metrics::MEMORY_PHASE_ONE_JOBS; +use crate::metrics::MEMORY_PHASE_ONE_OUTPUT; +use crate::metrics::MEMORY_PHASE_ONE_TOKEN_USAGE; use crate::runtime::MemoryStartupContext; use crate::runtime::StageOneRequestContext; use codex_config::types::MemoriesConfig; @@ -32,10 +36,6 @@ const JOB_LEASE_SECONDS: i64 = 3_600; const JOB_RETRY_DELAY_SECONDS: i64 = 3_600; const THREAD_SCAN_LIMIT: usize = 5_000; const PRUNE_BATCH_SIZE: usize = 200; -const MEMORY_PHASE_ONE_JOBS: &str = "codex.memory.phase1"; -const MEMORY_PHASE_ONE_E2E_MS: &str = "codex.memory.phase1.e2e_ms"; -const MEMORY_PHASE_ONE_OUTPUT: &str = "codex.memory.phase1.output"; -const MEMORY_PHASE_ONE_TOKEN_USAGE: &str = "codex.memory.phase1.token_usage"; struct JobResult { outcome: JobOutcome, diff --git a/codex-rs/memories/write/src/phase2.rs b/codex-rs/memories/write/src/phase2.rs index 2d092abfb2..14954c49e4 100644 --- a/codex-rs/memories/write/src/phase2.rs +++ b/codex-rs/memories/write/src/phase2.rs @@ -1,5 +1,9 @@ use crate::build_consolidation_prompt; use crate::memory_root; +use crate::metrics::MEMORY_PHASE_TWO_E2E_MS; +use crate::metrics::MEMORY_PHASE_TWO_INPUT; +use crate::metrics::MEMORY_PHASE_TWO_JOBS; +use crate::metrics::MEMORY_PHASE_TWO_TOKEN_USAGE; use crate::prune_old_extension_resources; use crate::rebuild_raw_memories_file_from_memories; use crate::runtime::MemoryStartupContext; @@ -30,10 +34,6 @@ const REASONING_EFFORT: codex_protocol::openai_models::ReasoningEffort = const JOB_LEASE_SECONDS: i64 = 3_600; const JOB_RETRY_DELAY_SECONDS: i64 = 3_600; const JOB_HEARTBEAT_SECONDS: u64 = 90; -const MEMORY_PHASE_TWO_JOBS: &str = "codex.memory.phase2"; -const MEMORY_PHASE_TWO_E2E_MS: &str = "codex.memory.phase2.e2e_ms"; -const MEMORY_PHASE_TWO_INPUT: &str = "codex.memory.phase2.input"; -const MEMORY_PHASE_TWO_TOKEN_USAGE: &str = "codex.memory.phase2.token_usage"; #[derive(Debug, Clone, Default)] struct Claim { diff --git a/codex-rs/memories/write/src/start.rs b/codex-rs/memories/write/src/start.rs index 0ad8b27301..f7bf11e6f6 100644 --- a/codex-rs/memories/write/src/start.rs +++ b/codex-rs/memories/write/src/start.rs @@ -1,4 +1,5 @@ use crate::guard; +use crate::metrics::MEMORY_STARTUP; use crate::phase1; use crate::phase2; use crate::runtime::MemoryStartupContext; @@ -12,8 +13,6 @@ use codex_protocol::protocol::SessionSource; use std::sync::Arc; use tracing::warn; -const MEMORY_STARTUP: &str = "codex.memory.startup"; - /// Starts the asynchronous startup memory pipeline for an eligible root session. /// /// The pipeline is skipped for ephemeral sessions, disabled feature flags, and From 598bbcdb5842452a4cce5dfec972e5ad4c66b0d9 Mon Sep 17 00:00:00 2001 From: friel-openai Date: Tue, 28 Apr 2026 08:46:13 -0700 Subject: [PATCH 14/53] Preserve assistant phase for replayed messages (#19832) --- codex-rs/core/src/codex_thread.rs | 8 +++++- codex-rs/core/src/goals.rs | 2 ++ codex-rs/core/src/session/handlers.rs | 1 + codex-rs/core/src/session/mod.rs | 2 ++ codex-rs/core/src/session/tests.rs | 9 ++++++- codex-rs/core/src/tasks/user_shell.rs | 11 ++++++++- codex-rs/protocol/src/models.rs | 35 +++++++++++++++++++++++++-- codex-rs/protocol/src/protocol.rs | 1 + 8 files changed, 64 insertions(+), 5 deletions(-) diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 47f1b99ce5..a2f64e86f4 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -456,9 +456,15 @@ impl CodexThread { fn pending_message_input_item(message: &ResponseItem) -> CodexResult { match message { - ResponseItem::Message { role, content, .. } => Ok(ResponseInputItem::Message { + ResponseItem::Message { + role, + content, + phase, + .. + } => Ok(ResponseInputItem::Message { role: role.clone(), content: content.clone(), + phase: phase.clone(), }), _ => Err(CodexErr::InvalidRequest( "append_message only supports ResponseItem::Message".to_string(), diff --git a/codex-rs/core/src/goals.rs b/codex-rs/core/src/goals.rs index f3c64f1b3a..f570ebfda3 100644 --- a/codex-rs/core/src/goals.rs +++ b/codex-rs/core/src/goals.rs @@ -1307,6 +1307,7 @@ impl Session { content: vec![ContentItem::InputText { text: continuation_prompt(&goal), }], + phase: None, }], }) } @@ -1452,6 +1453,7 @@ fn budget_limit_steering_item(goal: &ThreadGoal) -> ResponseInputItem { content: vec![ContentItem::InputText { text: budget_limit_prompt(goal), }], + phase: None, } } diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index 70900b4465..8f7255ec4e 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -1205,6 +1205,7 @@ Approved action: let items = vec![ResponseInputItem::Message { role: "developer".to_string(), content: vec![ContentItem::InputText { text }], + phase: None, }]; if let Err(items) = sess.inject_response_items(items).await { diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 34107e2f5d..d58e065f28 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -1699,6 +1699,7 @@ impl Session { .inject_response_items(vec![ResponseInputItem::Message { role: "developer".to_string(), content: vec![ContentItem::InputText { text }], + phase: None, }]) .await .is_err() @@ -1795,6 +1796,7 @@ impl Session { .inject_response_items(vec![ResponseInputItem::Message { role: "developer".to_string(), content: vec![ContentItem::InputText { text }], + phase: None, }]) .await .is_err() diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 731987faa9..8854744b76 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -6432,6 +6432,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() content: vec![ContentItem::InputText { text: "late pending input".to_string(), }], + phase: None, }]) .await .expect("inject pending input into active turn"); @@ -6677,18 +6678,21 @@ async fn prepend_pending_input_keeps_older_tail_ahead_of_newer_input() { content: vec![ContentItem::InputText { text: "blocked queued prompt".to_string(), }], + phase: None, }; let later = ResponseInputItem::Message { role: "user".to_string(), content: vec![ContentItem::InputText { text: "later queued prompt".to_string(), }], + phase: None, }; let newer = ResponseInputItem::Message { role: "user".to_string(), content: vec![ContentItem::InputText { text: "newer queued prompt".to_string(), }], + phase: None, }; sess.inject_response_items(vec![blocked.clone(), later.clone()]) @@ -6719,6 +6723,7 @@ async fn queued_response_items_for_next_turn_move_into_next_active_turn() { content: vec![ContentItem::InputText { text: "queued before wake".to_string(), }], + phase: None, }; sess.queue_response_items_for_next_turn(vec![queued_item.clone()]) @@ -6745,6 +6750,7 @@ async fn idle_interrupt_does_not_wake_queued_next_turn_items() { content: vec![ContentItem::InputText { text: "queued before interrupt".to_string(), }], + phase: None, }; sess.queue_response_items_for_next_turn(vec![queued_item]) @@ -6764,6 +6770,7 @@ async fn abort_empty_active_turn_preserves_pending_input() { content: vec![ContentItem::InputText { text: "late pending input".to_string(), }], + phase: None, }; let turn_state = { let mut active = sess.active_turn.lock().await; @@ -6969,7 +6976,7 @@ async fn budget_limited_accounting_steers_active_turn_without_aborting() -> anyh .await?; let pending_input = sess.get_pending_input().await; - let [ResponseInputItem::Message { role, content }] = pending_input.as_slice() else { + let [ResponseInputItem::Message { role, content, .. }] = pending_input.as_slice() else { panic!("expected one budget-limit steering message, got {pending_input:#?}"); }; assert_eq!("developer", role); diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 444b0c3ec2..23cd076404 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -354,7 +354,16 @@ async fn persist_user_shell_output( } let response_input_item = match output_item { - ResponseItem::Message { role, content, .. } => ResponseInputItem::Message { role, content }, + ResponseItem::Message { + role, + content, + phase, + .. + } => ResponseInputItem::Message { + role, + content, + phase, + }, _ => unreachable!("user shell command output record should always be a message"), }; diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 0431974d42..2d7dba7d54 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -609,6 +609,9 @@ pub enum ResponseInputItem { Message { role: String, content: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + phase: Option, }, FunctionCallOutput { call_id: String, @@ -1034,11 +1037,15 @@ pub fn local_image_content_items_with_label_number( impl From for ResponseItem { fn from(item: ResponseInputItem) -> Self { match item { - ResponseInputItem::Message { role, content } => Self::Message { + ResponseInputItem::Message { + role, + content, + phase, + } => Self::Message { role, content, id: None, - phase: None, + phase, }, ResponseInputItem::FunctionCallOutput { call_id, output } => { Self::FunctionCallOutput { call_id, output } @@ -1176,6 +1183,7 @@ impl From> for ResponseInputItem { UserInput::Skill { .. } | UserInput::Mention { .. } => Vec::new(), // Tool bodies are injected later in core }) .collect::>(), + phase: None, } } } @@ -1581,6 +1589,29 @@ mod tests { use std::path::PathBuf; use tempfile::tempdir; + #[test] + fn response_input_message_conversion_preserves_phase() { + let item = ResponseItem::from(ResponseInputItem::Message { + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: "still working".to_string(), + }], + phase: Some(MessagePhase::Commentary), + }); + + assert_eq!( + item, + ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: "still working".to_string(), + }], + phase: Some(MessagePhase::Commentary), + } + ); + } + #[test] fn sandbox_permissions_helpers_match_documented_semantics() { let cases = [ diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index c39c017fe4..2876627025 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -855,6 +855,7 @@ impl InterAgentCommunication { content: vec![ContentItem::OutputText { text: serde_json::to_string(self).unwrap_or_default(), }], + phase: Some(MessagePhase::Commentary), } } From a61c7850400b9818aae310f762495159476221c4 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 28 Apr 2026 08:51:44 -0700 Subject: [PATCH 15/53] Reset TUI keyboard reporting on exit (#19625) ## Why Codex enables enhanced keyboard reporting while the TUI owns the terminal. In iTerm2, exiting the TUI with Ctrl+C can intermittently leave the parent shell receiving raw CSI-u / `modifyOtherKeys` fragments instead of normal key input. Final terminal cleanup should put the parent shell back into normal keyboard reporting even if the terminal misses the usual stack pop. Fixes #19553. ## What Changed - Move TUI keyboard enhancement setup and detection into `tui/src/tui/keyboard_modes.rs`. - Add an exit-only `restore_after_exit()` path that performs the normal keyboard enhancement pop plus unconditional keyboard enhancement and `modifyOtherKeys` resets. - Keep temporary restore paths, such as external-editor handoff, using the balanced stack pop behavior. ## Confidence Medium. This is a speculative fix: I was not able to reproduce the reported iTerm2 behavior manually, but the symptoms line up with terminal keyboard reporting state surviving Codex exit. The added reset sequences are scoped to final TUI shutdown and should be harmless when the terminal is already clean. --- codex-rs/tui/src/lib.rs | 4 +- codex-rs/tui/src/tui.rs | 239 ++++----------------- codex-rs/tui/src/tui/keyboard_modes.rs | 280 +++++++++++++++++++++++++ 3 files changed, 328 insertions(+), 195 deletions(-) create mode 100644 codex-rs/tui/src/tui/keyboard_modes.rs diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 654e90a63d..6ecd01b76a 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -1593,7 +1593,7 @@ pub(crate) async fn resolve_cwd_for_resume_or_fork( reason = "TUI should no longer be displayed, so we can write to stderr." )] fn restore() { - if let Err(err) = tui::restore() { + if let Err(err) = tui::restore_after_exit() { eprintln!( "failed to restore terminal. Run `reset` or restart your terminal to recover: {err}" ); @@ -1612,7 +1612,7 @@ impl TerminalRestoreGuard { #[cfg_attr(debug_assertions, allow(dead_code))] fn restore(&mut self) -> color_eyre::Result<()> { if self.active { - crate::tui::restore()?; + crate::tui::restore_after_exit()?; self.active = false; } Ok(()) diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 79e4cf7d25..30af7804a8 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -19,9 +19,6 @@ use crossterm::event::DisableFocusChange; use crossterm::event::EnableBracketedPaste; use crossterm::event::EnableFocusChange; use crossterm::event::KeyEvent; -use crossterm::event::KeyboardEnhancementFlags; -use crossterm::event::PopKeyboardEnhancementFlags; -use crossterm::event::PushKeyboardEnhancementFlags; use crossterm::terminal::EnterAlternateScreen; use crossterm::terminal::LeaveAlternateScreen; use crossterm::terminal::supports_keyboard_enhancement; @@ -55,115 +52,16 @@ mod frame_rate_limiter; mod frame_requester; #[cfg(unix)] mod job_control; +mod keyboard_modes; /// Target frame interval for UI redraw scheduling. pub(crate) const TARGET_FRAME_INTERVAL: Duration = frame_rate_limiter::MIN_FRAME_INTERVAL; -const DISABLE_KEYBOARD_ENHANCEMENT_ENV_VAR: &str = "CODEX_TUI_DISABLE_KEYBOARD_ENHANCEMENT"; /// A type alias for the terminal type used in this application pub type Terminal = CustomTerminal>; -fn keyboard_enhancement_disabled() -> bool { - let disable_env = std::env::var(DISABLE_KEYBOARD_ENHANCEMENT_ENV_VAR).ok(); - let is_wsl = running_in_wsl(); - let is_vscode_terminal = is_wsl && running_in_vscode_terminal(); - keyboard_enhancement_disabled_for(disable_env.as_deref(), is_wsl, is_vscode_terminal) -} - -fn keyboard_enhancement_disabled_for( - disable_env: Option<&str>, - is_wsl: bool, - is_vscode_terminal: bool, -) -> bool { - if let Some(disabled) = parse_bool_env(disable_env) { - return disabled; - } - - // VS Code running a WSL shell can hide TERM_PROGRAM from the Linux process - // environment, so `running_in_vscode_terminal` also probes the Windows-side - // environment through WSL interop. - is_wsl && is_vscode_terminal -} - -fn parse_bool_env(value: Option<&str>) -> Option { - match value.map(str::trim) { - Some("1") => Some(true), - Some(value) if value.eq_ignore_ascii_case("true") => Some(true), - Some(value) if value.eq_ignore_ascii_case("yes") => Some(true), - Some("0") => Some(false), - Some(value) if value.eq_ignore_ascii_case("false") => Some(false), - Some(value) if value.eq_ignore_ascii_case("no") => Some(false), - _ => None, - } -} - -fn running_in_wsl() -> bool { - #[cfg(target_os = "linux")] - { - crate::clipboard_paste::is_probably_wsl() - } - - #[cfg(not(target_os = "linux"))] - { - false - } -} - pub(crate) fn running_in_vscode_terminal() -> bool { - vscode_terminal_detected( - std::env::var("TERM_PROGRAM").ok().as_deref(), - windows_term_program().as_deref(), - ) -} - -fn vscode_terminal_detected( - linux_term_program: Option<&str>, - windows_term_program: Option<&str>, -) -> bool { - term_program_is_vscode(linux_term_program) || term_program_is_vscode(windows_term_program) -} - -fn term_program_is_vscode(value: Option<&str>) -> bool { - value.is_some_and(|value| value.eq_ignore_ascii_case("vscode")) -} - -fn windows_term_program() -> Option { - #[cfg(target_os = "linux")] - { - static WINDOWS_TERM_PROGRAM: std::sync::OnceLock> = - std::sync::OnceLock::new(); - WINDOWS_TERM_PROGRAM - .get_or_init(read_windows_term_program) - .clone() - } - - #[cfg(not(target_os = "linux"))] - { - None - } -} - -#[cfg(target_os = "linux")] -fn read_windows_term_program() -> Option { - let output = std::process::Command::new("cmd.exe") - .args(["/d", "/s", "/c", "set TERM_PROGRAM"]) - .stdin(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .output() - .ok()?; - - if !output.status.success() { - return None; - } - - String::from_utf8_lossy(&output.stdout) - .lines() - .find_map(|line| { - line.trim_end_matches('\r') - .strip_prefix("TERM_PROGRAM=") - .map(str::to_string) - }) - .filter(|value| !value.trim().is_empty()) + keyboard_modes::running_in_vscode_terminal() } fn should_emit_notification(condition: NotificationCondition, terminal_focused: bool) -> bool { @@ -175,10 +73,7 @@ fn should_emit_notification(condition: NotificationCondition, terminal_focused: #[cfg(test)] mod tests { - use super::keyboard_enhancement_disabled_for; - use super::parse_bool_env; use super::should_emit_notification; - use super::vscode_terminal_detected; use codex_config::types::NotificationCondition; #[test] @@ -204,68 +99,6 @@ mod tests { /*terminal_focused*/ false )); } - - #[test] - fn keyboard_enhancement_env_flag_parses_common_values() { - assert_eq!(parse_bool_env(Some("1")), Some(true)); - assert_eq!(parse_bool_env(Some("true")), Some(true)); - assert_eq!(parse_bool_env(Some("YES")), Some(true)); - assert_eq!(parse_bool_env(Some("0")), Some(false)); - assert_eq!(parse_bool_env(Some("false")), Some(false)); - assert_eq!(parse_bool_env(Some("NO")), Some(false)); - assert_eq!(parse_bool_env(Some("unexpected")), None); - assert_eq!(parse_bool_env(/*value*/ None), None); - } - - #[test] - fn keyboard_enhancement_auto_disables_for_vscode_in_wsl() { - assert!(keyboard_enhancement_disabled_for( - /*disable_env*/ None, /*is_wsl*/ true, /*is_vscode_terminal*/ true - )); - } - - #[test] - fn keyboard_enhancement_auto_disable_requires_wsl_and_vscode() { - assert!(!keyboard_enhancement_disabled_for( - /*disable_env*/ None, /*is_wsl*/ true, /*is_vscode_terminal*/ false - )); - assert!(!keyboard_enhancement_disabled_for( - /*disable_env*/ None, /*is_wsl*/ false, /*is_vscode_terminal*/ true - )); - } - - #[test] - fn keyboard_enhancement_env_flag_overrides_auto_detection() { - assert!(!keyboard_enhancement_disabled_for( - Some("0"), - /*is_wsl*/ true, - /*is_vscode_terminal*/ true - )); - assert!(keyboard_enhancement_disabled_for( - Some("1"), - /*is_wsl*/ false, - /*is_vscode_terminal*/ false - )); - } - - #[test] - fn vscode_terminal_detection_uses_linux_and_windows_term_program() { - assert!(vscode_terminal_detected( - Some("vscode"), - /*windows_term_program*/ None - )); - assert!(vscode_terminal_detected( - /*linux_term_program*/ None, - Some("vscode") - )); - assert!(!vscode_terminal_detected( - /*linux_term_program*/ None, - Some("WindowsTerminal") - )); - assert!(!vscode_terminal_detected( - /*linux_term_program*/ None, /*windows_term_program*/ None - )); - } } pub fn set_modes() -> Result<()> { @@ -278,16 +111,7 @@ pub fn set_modes() -> Result<()> { // Some terminals (notably legacy Windows consoles) do not support // keyboard enhancement flags. Attempt to enable them, but continue // gracefully if unsupported. - if !keyboard_enhancement_disabled() { - let _ = execute!( - stdout(), - PushKeyboardEnhancementFlags( - KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES - | KeyboardEnhancementFlags::REPORT_EVENT_TYPES - | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS - ) - ); - } + keyboard_modes::enable_keyboard_enhancement(); let _ = execute!(stdout(), EnableFocusChange); Ok(()) @@ -335,29 +159,58 @@ impl Command for DisableAlternateScroll { } } -fn restore_common(should_disable_raw_mode: bool) -> Result<()> { - // Pop may fail on platforms that didn't support the push; ignore errors. - let _ = execute!(stdout(), PopKeyboardEnhancementFlags); - execute!(stdout(), DisableBracketedPaste)?; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RawModeRestore { + Disable, + Keep, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum KeyboardRestore { + PopStack, + ResetAfterExit, +} + +fn restore_common( + raw_mode_restore: RawModeRestore, + keyboard_restore: KeyboardRestore, +) -> Result<()> { + match keyboard_restore { + KeyboardRestore::PopStack => keyboard_modes::restore_keyboard_enhancement_stack(), + KeyboardRestore::ResetAfterExit => keyboard_modes::reset_keyboard_reporting_after_exit(), + } + + let mut first_error = execute!(stdout(), DisableBracketedPaste).err(); let _ = execute!(stdout(), DisableFocusChange); - if should_disable_raw_mode { - disable_raw_mode()?; + if matches!(raw_mode_restore, RawModeRestore::Disable) + && let Err(err) = disable_raw_mode() + { + first_error.get_or_insert(err); } let _ = execute!(stdout(), crossterm::cursor::Show); - Ok(()) + match first_error { + Some(err) => Err(err), + None => Ok(()), + } } /// Restore the terminal to its original state. /// Inverse of `set_modes`. pub fn restore() -> Result<()> { - let should_disable_raw_mode = true; - restore_common(should_disable_raw_mode) + restore_common(RawModeRestore::Disable, KeyboardRestore::PopStack) +} + +/// Restore the terminal after Codex is exiting. +/// +/// Uses a stronger keyboard reset than [`restore`] so the parent shell recovers even if a +/// terminal missed the stack pop that normally pairs with [`set_modes`]. +pub fn restore_after_exit() -> Result<()> { + restore_common(RawModeRestore::Disable, KeyboardRestore::ResetAfterExit) } /// Restore the terminal to its original state, but keep raw mode enabled. pub fn restore_keep_raw() -> Result<()> { - let should_disable_raw_mode = false; - restore_common(should_disable_raw_mode) + restore_common(RawModeRestore::Keep, KeyboardRestore::PopStack) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -437,7 +290,7 @@ pub fn init() -> Result { fn set_panic_hook() { let hook = panic::take_hook(); panic::set_hook(Box::new(move |panic_info| { - let _ = restore(); // ignore any errors as we are already failing + let _ = restore_after_exit(); // ignore any errors as we are already failing hook(panic_info); })); } @@ -485,8 +338,8 @@ impl Tui { // Detect keyboard enhancement support before any EventStream is created so the // crossterm poller can acquire its lock without contention. - let enhanced_keys_supported = - !keyboard_enhancement_disabled() && supports_keyboard_enhancement().unwrap_or(false); + let enhanced_keys_supported = !keyboard_modes::keyboard_enhancement_disabled() + && supports_keyboard_enhancement().unwrap_or(false); // Cache this to avoid contention with the event reader. supports_color::on_cached(supports_color::Stream::Stdout); let _ = crate::terminal_palette::default_colors(); diff --git a/codex-rs/tui/src/tui/keyboard_modes.rs b/codex-rs/tui/src/tui/keyboard_modes.rs new file mode 100644 index 0000000000..0b907caf30 --- /dev/null +++ b/codex-rs/tui/src/tui/keyboard_modes.rs @@ -0,0 +1,280 @@ +//! Terminal keyboard enhancement setup and teardown helpers. +//! +//! The TUI uses crossterm's keyboard enhancement stack while it owns the terminal, but +//! process exit gets a stronger reset so the parent shell does not inherit enhanced key +//! reporting if a terminal misses the normal stack pop. + +use std::fmt; +use std::io::stdout; + +use crossterm::Command; +use crossterm::event::KeyboardEnhancementFlags; +use crossterm::event::PopKeyboardEnhancementFlags; +use crossterm::event::PushKeyboardEnhancementFlags; +use ratatui::crossterm::execute; + +const DISABLE_KEYBOARD_ENHANCEMENT_ENV_VAR: &str = "CODEX_TUI_DISABLE_KEYBOARD_ENHANCEMENT"; + +pub(super) fn keyboard_enhancement_disabled() -> bool { + let disable_env = std::env::var(DISABLE_KEYBOARD_ENHANCEMENT_ENV_VAR).ok(); + let is_wsl = running_in_wsl(); + let is_vscode_terminal = is_wsl && running_in_vscode_terminal(); + keyboard_enhancement_disabled_for(disable_env.as_deref(), is_wsl, is_vscode_terminal) +} + +fn keyboard_enhancement_disabled_for( + disable_env: Option<&str>, + is_wsl: bool, + is_vscode_terminal: bool, +) -> bool { + if let Some(disabled) = parse_bool_env(disable_env) { + return disabled; + } + + // VS Code running a WSL shell can hide TERM_PROGRAM from the Linux process + // environment, so `running_in_vscode_terminal` also probes the Windows-side + // environment through WSL interop. + is_wsl && is_vscode_terminal +} + +fn parse_bool_env(value: Option<&str>) -> Option { + match value.map(str::trim) { + Some("1") => Some(true), + Some(value) if value.eq_ignore_ascii_case("true") => Some(true), + Some(value) if value.eq_ignore_ascii_case("yes") => Some(true), + Some("0") => Some(false), + Some(value) if value.eq_ignore_ascii_case("false") => Some(false), + Some(value) if value.eq_ignore_ascii_case("no") => Some(false), + _ => None, + } +} + +fn running_in_wsl() -> bool { + #[cfg(target_os = "linux")] + { + crate::clipboard_paste::is_probably_wsl() + } + + #[cfg(not(target_os = "linux"))] + { + false + } +} + +pub(super) fn running_in_vscode_terminal() -> bool { + vscode_terminal_detected( + std::env::var("TERM_PROGRAM").ok().as_deref(), + windows_term_program().as_deref(), + ) +} + +fn vscode_terminal_detected( + linux_term_program: Option<&str>, + windows_term_program: Option<&str>, +) -> bool { + term_program_is_vscode(linux_term_program) || term_program_is_vscode(windows_term_program) +} + +fn term_program_is_vscode(value: Option<&str>) -> bool { + value.is_some_and(|value| value.eq_ignore_ascii_case("vscode")) +} + +fn windows_term_program() -> Option { + #[cfg(target_os = "linux")] + { + static WINDOWS_TERM_PROGRAM: std::sync::OnceLock> = + std::sync::OnceLock::new(); + WINDOWS_TERM_PROGRAM + .get_or_init(read_windows_term_program) + .clone() + } + + #[cfg(not(target_os = "linux"))] + { + None + } +} + +#[cfg(target_os = "linux")] +fn read_windows_term_program() -> Option { + let output = std::process::Command::new("cmd.exe") + .args(["/d", "/s", "/c", "set TERM_PROGRAM"]) + .stdin(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + String::from_utf8_lossy(&output.stdout) + .lines() + .find_map(|line| { + line.trim_end_matches('\r') + .strip_prefix("TERM_PROGRAM=") + .map(str::to_string) + }) + .filter(|value| !value.trim().is_empty()) +} + +pub(super) fn enable_keyboard_enhancement() { + if keyboard_enhancement_disabled() { + return; + } + + let _ = execute!( + stdout(), + PushKeyboardEnhancementFlags( + KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES + | KeyboardEnhancementFlags::REPORT_EVENT_TYPES + | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS + ) + ); +} + +pub(super) fn restore_keyboard_enhancement_stack() { + let _ = execute!(stdout(), PopKeyboardEnhancementFlags); +} + +pub(super) fn reset_keyboard_reporting_after_exit() { + let _ = execute!( + stdout(), + PopKeyboardEnhancementFlags, + ResetKeyboardEnhancementFlags, + DisableModifyOtherKeys + ); +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ResetKeyboardEnhancementFlags; + +impl Command for ResetKeyboardEnhancementFlags { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + f.write_str("\x1b[ std::io::Result<()> { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "keyboard enhancement reset is not implemented for the legacy Windows API", + )) + } + + #[cfg(windows)] + fn is_ansi_code_supported(&self) -> bool { + false + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct DisableModifyOtherKeys; + +impl Command for DisableModifyOtherKeys { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + f.write_str("\x1b[>4;0m") + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "modifyOtherKeys reset is not implemented for the legacy Windows API", + )) + } + + #[cfg(windows)] + fn is_ansi_code_supported(&self) -> bool { + false + } +} + +#[cfg(test)] +mod tests { + use super::DisableModifyOtherKeys; + use super::ResetKeyboardEnhancementFlags; + use super::keyboard_enhancement_disabled_for; + use super::parse_bool_env; + use super::vscode_terminal_detected; + use crossterm::Command; + use pretty_assertions::assert_eq; + + fn ansi_for(command: impl Command) -> String { + let mut out = String::new(); + command.write_ansi(&mut out).unwrap(); + out + } + + #[test] + fn keyboard_enhancement_env_flag_parses_common_values() { + assert_eq!(parse_bool_env(Some("1")), Some(true)); + assert_eq!(parse_bool_env(Some("true")), Some(true)); + assert_eq!(parse_bool_env(Some("YES")), Some(true)); + assert_eq!(parse_bool_env(Some("0")), Some(false)); + assert_eq!(parse_bool_env(Some("false")), Some(false)); + assert_eq!(parse_bool_env(Some("NO")), Some(false)); + assert_eq!(parse_bool_env(Some("unexpected")), None); + assert_eq!(parse_bool_env(/*value*/ None), None); + } + + #[test] + fn keyboard_enhancement_auto_disables_for_vscode_in_wsl() { + assert!(keyboard_enhancement_disabled_for( + /*disable_env*/ None, /*is_wsl*/ true, /*is_vscode_terminal*/ true + )); + } + + #[test] + fn keyboard_enhancement_auto_disable_requires_wsl_and_vscode() { + assert!(!keyboard_enhancement_disabled_for( + /*disable_env*/ None, /*is_wsl*/ true, /*is_vscode_terminal*/ false + )); + assert!(!keyboard_enhancement_disabled_for( + /*disable_env*/ None, /*is_wsl*/ false, /*is_vscode_terminal*/ true + )); + } + + #[test] + fn keyboard_enhancement_env_flag_overrides_auto_detection() { + assert!(!keyboard_enhancement_disabled_for( + Some("0"), + /*is_wsl*/ true, + /*is_vscode_terminal*/ true + )); + assert!(keyboard_enhancement_disabled_for( + Some("1"), + /*is_wsl*/ false, + /*is_vscode_terminal*/ false + )); + } + + #[test] + fn vscode_terminal_detection_uses_linux_and_windows_term_program() { + assert!(vscode_terminal_detected( + Some("vscode"), + /*windows_term_program*/ None + )); + assert!(vscode_terminal_detected( + /*linux_term_program*/ None, + Some("vscode") + )); + assert!(!vscode_terminal_detected( + /*linux_term_program*/ None, + Some("WindowsTerminal") + )); + assert!(!vscode_terminal_detected( + /*linux_term_program*/ None, /*windows_term_program*/ None + )); + } + + #[test] + fn reset_keyboard_enhancement_flags_clears_all_pushed_levels() { + assert_eq!(ansi_for(ResetKeyboardEnhancementFlags), "\x1b[4;0m"); + } +} From 5e737372ee09db8a0941ca0946385f64a854de61 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Tue, 28 Apr 2026 12:52:25 -0300 Subject: [PATCH 16/53] feat(tui): add configurable keymap support (#18593) ## Why The TUI currently handles keyboard shortcuts as hard-coded event matches spread across app, composer, pager, list, approval, and navigation code. That makes shortcuts hard to customize, makes displayed hints easy to drift from actual behavior, and makes future keymap work riskier because there is no central action inventory. This PR adds the foundation for configurable, action-based keymaps without adding the interactive remapping UI yet. Onboarding intentionally stays on fixed startup shortcuts because users cannot reasonably configure keymaps before completing onboarding. This is PR1 in the keymap stack: - PR1: #18593: configurable keymap foundation - PR2: #18594: `/keymap` picker and guided remapping UI - PR3: #18595: Vim composer mode and the remap option ## Design Notes The new model resolves named actions into concrete runtime bindings once from config, then passes those bindings to the UI surfaces that handle input or render shortcut hints. The main concepts are: - **Context**: a scope where an action is active, such as `global`, `chat`, `composer`, `editor`, `pager`, `list`, or `approval`. - **Action**: a named operation inside a context, such as `global.open_transcript`, `composer.submit`, or `pager.close`. - **Binding**: one or more single-key shortcuts assigned to an action, written as config strings such as `ctrl-t`, `alt-backspace`, or `page-down`. Multi-step sequences such as `ctrl-x ctrl-s`, `g g`, or leader-key flows are not part of this PR. - **Resolution order**: context-specific config wins first, supported global fallbacks come next, and built-in defaults fill in anything unset. - **Explicit unbinding**: an empty array removes an action binding in that scope and does not fall through to a fallback binding. - **Conflict validation**: a resolved keymap rejects duplicate active bindings inside the same scope so one keypress cannot dispatch two actions. ## What Changed - Added `TuiKeymap` config support under `[tui.keymap]`, including typed contexts/actions, key alias normalization, generated schema coverage, and user-facing config errors. - Added `RuntimeKeymap` resolution in `codex-rs/tui/src/keymap.rs`, including fallback precedence, built-in defaults, explicit unbinding, and per-context conflict validation. - Rewired existing TUI handlers to consume resolved keymap actions instead of directly matching hard-coded keys in each component. - Updated key hint rendering and footer/pager/list surfaces so displayed shortcuts follow the resolved keymap. - Kept onboarding shortcuts fixed in `codex-rs/tui/src/onboarding/keys.rs` instead of exposing them through `[tui.keymap]`. ## Validation The branch includes focused coverage for config parsing, key normalization, runtime fallback resolution, explicit unbinding, duplicate-key conflict validation, default keymap consistency, onboarding startup key behavior, and UI hint snapshots affected by resolved key bindings. --- codex-rs/config/src/lib.rs | 1 + codex-rs/config/src/tui_keymap.rs | 483 +++++ codex-rs/config/src/types.rs | 18 + codex-rs/core/config.schema.json | 687 +++++++ codex-rs/core/src/config/config_tests.rs | 7 + codex-rs/core/src/config/edit.rs | 39 + codex-rs/core/src/config/edit_tests.rs | 165 ++ codex-rs/core/src/config/mod.rs | 16 +- codex-rs/tui/src/app.rs | 11 + codex-rs/tui/src/app/event_dispatch.rs | 149 ++ codex-rs/tui/src/app/input.rs | 88 +- codex-rs/tui/src/app/test_support.rs | 1 + codex-rs/tui/src/app/tests.rs | 12 +- codex-rs/tui/src/app_backtrack.rs | 6 +- codex-rs/tui/src/app_event.rs | 40 + .../tui/src/bottom_pane/approval_overlay.rs | 746 ++++++-- codex-rs/tui/src/bottom_pane/chat_composer.rs | 255 ++- .../chat_composer/history_search.rs | 48 +- codex-rs/tui/src/bottom_pane/footer.rs | 137 +- .../src/bottom_pane/list_selection_view.rs | 219 ++- codex-rs/tui/src/bottom_pane/mod.rs | 145 +- .../src/bottom_pane/pending_input_preview.rs | 14 +- codex-rs/tui/src/bottom_pane/popup_consts.rs | 42 +- ...__approval_overlay_permissions_prompt.snap | 2 +- ...__tests__footer_mode_shortcut_overlay.snap | 4 +- ...shortcuts_collaboration_modes_enabled.snap | 5 +- ...tests__footer_shortcuts_shift_and_esc.snap | 4 +- codex-rs/tui/src/bottom_pane/textarea.rs | 396 ++-- codex-rs/tui/src/chatwidget.rs | 113 +- codex-rs/tui/src/chatwidget/keymap_picker.rs | 165 ++ .../tui/src/chatwidget/reasoning_shortcuts.rs | 30 +- codex-rs/tui/src/chatwidget/slash_dispatch.rs | 4 + ...dget__tests__apps_popup_loading_state.snap | 4 +- ...dget__tests__feedback_selection_popup.snap | 4 +- ...s__model_picker_filters_hidden_models.snap | 4 +- ...twidget__tests__model_selection_popup.snap | 2 +- ...t__tests__plugins_popup_loading_state.snap | 4 +- .../chatwidget/tests/composer_submission.rs | 23 +- codex-rs/tui/src/chatwidget/tests/helpers.rs | 9 +- .../src/chatwidget/tests/slash_commands.rs | 59 + codex-rs/tui/src/key_hint.rs | 148 +- codex-rs/tui/src/keymap.rs | 1530 +++++++++++++++ codex-rs/tui/src/keymap_setup.rs | 1652 +++++++++++++++++ codex-rs/tui/src/keymap_setup/actions.rs | 235 +++ codex-rs/tui/src/keymap_setup/picker.rs | 386 ++++ codex-rs/tui/src/lib.rs | 2 + codex-rs/tui/src/onboarding/auth.rs | 203 +- .../onboarding/auth/headless_chatgpt_login.rs | 6 +- codex-rs/tui/src/onboarding/keys.rs | 39 + codex-rs/tui/src/onboarding/mod.rs | 1 + .../tui/src/onboarding/onboarding_screen.rs | 160 +- .../tui/src/onboarding/trust_directory.rs | 32 +- codex-rs/tui/src/onboarding/welcome.rs | 42 +- codex-rs/tui/src/pager_overlay.rs | 254 ++- codex-rs/tui/src/slash_command.rs | 3 + ...ymap_setup__tests__keymap_action_menu.snap | 25 + ...map_setup__tests__keymap_capture_view.snap | 30 + ...__tests__keymap_picker_all_tab_search.snap | 16 + ...ap_setup__tests__keymap_picker_custom.snap | 22 + ...p__tests__keymap_picker_first_actions.snap | 25 + ...ap_setup__tests__keymap_picker_narrow.snap | 23 + ...ymap_setup__tests__keymap_picker_wide.snap | 22 + codex-rs/tui/tooltips.txt | 2 + 63 files changed, 8142 insertions(+), 877 deletions(-) create mode 100644 codex-rs/config/src/tui_keymap.rs create mode 100644 codex-rs/tui/src/chatwidget/keymap_picker.rs create mode 100644 codex-rs/tui/src/keymap.rs create mode 100644 codex-rs/tui/src/keymap_setup.rs create mode 100644 codex-rs/tui/src/keymap_setup/actions.rs create mode 100644 codex-rs/tui/src/keymap_setup/picker.rs create mode 100644 codex-rs/tui/src/onboarding/keys.rs create mode 100644 codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_action_menu.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_capture_view.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index d628fbf04f..291c3609a5 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -21,6 +21,7 @@ pub mod schema; mod skills_config; mod state; mod thread_config; +mod tui_keymap; pub mod types; pub const CONFIG_TOML_FILE: &str = "config.toml"; diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs new file mode 100644 index 0000000000..376effd645 --- /dev/null +++ b/codex-rs/config/src/tui_keymap.rs @@ -0,0 +1,483 @@ +//! TUI keymap config schema and canonical key-spec normalization. +//! +//! This module defines the on-disk `[tui.keymap]` contract used by +//! `~/.codex/config.toml` and normalizes user-entered key specs into canonical +//! forms consumed by runtime keymap resolution in `codex-rs/tui/src/keymap.rs`. +//! +//! Responsibilities: +//! +//! 1. Define strongly typed config contexts/actions with unknown-field +//! rejection. +//! 2. Normalize accepted key aliases into canonical names. +//! 3. Reject malformed bindings early with user-facing diagnostics. +//! +//! Non-responsibilities: +//! +//! 1. Dispatch precedence and conflict validation. +//! 2. Input event matching at runtime. + +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Deserializer; +use serde::Serialize; +use serde::de::Error as SerdeError; +use std::collections::BTreeMap; + +/// Normalized string representation of a single key event (for example `ctrl-a`). +/// +/// The parser accepts a small alias set (for example `escape` -> `esc`, +/// `pageup` -> `page-up`) and stores the canonical form. +/// +/// This deliberately represents one terminal key event, not a sequence of +/// events. A value like `ctrl-x ctrl-s` is not a chord in this schema; adding +/// multi-step chords would require a separate runtime state machine. +#[derive(Serialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[serde(transparent)] +pub struct KeybindingSpec(#[schemars(with = "String")] pub String); + +impl KeybindingSpec { + /// Returns the canonical key-spec string (for example `ctrl-a`). + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +impl<'de> Deserialize<'de> for KeybindingSpec { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let raw = String::deserialize(deserializer)?; + let normalized = normalize_keybinding_spec(&raw).map_err(SerdeError::custom)?; + Ok(Self(normalized)) + } +} + +/// One action binding value in config. +/// +/// This accepts either: +/// +/// 1. A single key spec string (`"ctrl-a"`). +/// 2. A list of key spec strings (`["ctrl-a", "alt-a"]`). +/// +/// An empty list explicitly unbinds the action in that scope. Because an +/// explicit empty list is still a configured value, runtime resolution must not +/// fall through to global or built-in defaults for that action. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[serde(untagged)] +pub enum KeybindingsSpec { + One(KeybindingSpec), + Many(Vec), +} + +impl KeybindingsSpec { + /// Returns all configured key specs for one action in declaration order. + /// + /// Callers should preserve this ordering when deriving UI hints so the + /// first binding remains the primary affordance shown to users. + pub fn specs(&self) -> Vec<&KeybindingSpec> { + match self { + Self::One(spec) => vec![spec], + Self::Many(specs) => specs.iter().collect(), + } + } +} + +/// Global keybindings. These are used when a context does not define an override. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(deny_unknown_fields)] +pub struct TuiGlobalKeymap { + /// Open the transcript overlay. + pub open_transcript: Option, + /// Open the external editor for the current draft. + pub open_external_editor: Option, + /// Copy the last agent response to the clipboard. + pub copy: Option, + /// Clear the terminal UI. + pub clear_terminal: Option, + /// Submit the current composer draft. + pub submit: Option, + /// Queue the current composer draft while a task is running. + pub queue: Option, + /// Toggle the composer shortcut overlay. + pub toggle_shortcuts: Option, +} + +/// Chat context keybindings. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(deny_unknown_fields)] +pub struct TuiChatKeymap { + /// Decrease the active reasoning effort. + pub decrease_reasoning_effort: Option, + /// Increase the active reasoning effort. + pub increase_reasoning_effort: Option, + /// Edit the most recently queued message. + pub edit_queued_message: Option, +} + +/// Composer context keybindings. These override corresponding `global` actions. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(deny_unknown_fields)] +pub struct TuiComposerKeymap { + /// Submit the current composer draft. + pub submit: Option, + /// Queue the current composer draft while a task is running. + pub queue: Option, + /// Toggle the composer shortcut overlay. + pub toggle_shortcuts: Option, + /// Open reverse history search or move to the previous match. + pub history_search_previous: Option, + /// Move to the next match in reverse history search. + pub history_search_next: Option, +} + +/// Editor context keybindings for text editing inside text areas. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(deny_unknown_fields)] +pub struct TuiEditorKeymap { + /// Insert a newline in the editor. + pub insert_newline: Option, + /// Move cursor left by one grapheme. + pub move_left: Option, + /// Move cursor right by one grapheme. + pub move_right: Option, + /// Move cursor up one visual line. + pub move_up: Option, + /// Move cursor down one visual line. + pub move_down: Option, + /// Move cursor to beginning of previous word. + pub move_word_left: Option, + /// Move cursor to end of next word. + pub move_word_right: Option, + /// Move cursor to beginning of line. + pub move_line_start: Option, + /// Move cursor to end of line. + pub move_line_end: Option, + /// Delete one grapheme to the left. + pub delete_backward: Option, + /// Delete one grapheme to the right. + pub delete_forward: Option, + /// Delete the previous word. + pub delete_backward_word: Option, + /// Delete the next word. + pub delete_forward_word: Option, + /// Kill text from cursor to line start. + pub kill_line_start: Option, + /// Kill text from cursor to line end. + pub kill_line_end: Option, + /// Yank the kill buffer. + pub yank: Option, +} + +/// Pager context keybindings for transcript and static overlays. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(deny_unknown_fields)] +pub struct TuiPagerKeymap { + /// Scroll up by one row. + pub scroll_up: Option, + /// Scroll down by one row. + pub scroll_down: Option, + /// Scroll up by one page. + pub page_up: Option, + /// Scroll down by one page. + pub page_down: Option, + /// Scroll up by half a page. + pub half_page_up: Option, + /// Scroll down by half a page. + pub half_page_down: Option, + /// Jump to the beginning. + pub jump_top: Option, + /// Jump to the end. + pub jump_bottom: Option, + /// Close the pager overlay. + pub close: Option, + /// Close the transcript overlay via its dedicated toggle key. + pub close_transcript: Option, +} + +/// List selection context keybindings for popup-style selectable lists. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(deny_unknown_fields)] +pub struct TuiListKeymap { + /// Move list selection up. + pub move_up: Option, + /// Move list selection down. + pub move_down: Option, + /// Accept current selection. + pub accept: Option, + /// Cancel and close selection view. + pub cancel: Option, +} + +/// Approval overlay keybindings. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(deny_unknown_fields)] +pub struct TuiApprovalKeymap { + /// Open the full-screen approval details view. + pub open_fullscreen: Option, + /// Open the thread that requested approval when shown from another thread. + pub open_thread: Option, + /// Approve the primary option. + pub approve: Option, + /// Approve for session when that option exists. + pub approve_for_session: Option, + /// Approve with exec-policy prefix when that option exists. + pub approve_for_prefix: Option, + /// Deny without providing follow-up guidance. + pub deny: Option, + /// Decline and provide corrective guidance. + pub decline: Option, + /// Cancel an elicitation request. + pub cancel: Option, +} + +/// Raw keymap configuration from `[tui.keymap]`. +/// +/// Each context contains action-level overrides. Missing actions inherit from +/// built-in defaults, and selected chat/composer actions can fall back +/// through `global` during runtime resolution. +/// +/// This type is intentionally a persistence shape, not the structure used by +/// input handlers. Runtime consumers should resolve it into +/// `RuntimeKeymap` first so precedence, empty-list unbinding, and duplicate-key +/// validation are applied consistently. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(deny_unknown_fields)] +pub struct TuiKeymap { + #[serde(default)] + pub global: TuiGlobalKeymap, + #[serde(default)] + pub chat: TuiChatKeymap, + #[serde(default)] + pub composer: TuiComposerKeymap, + #[serde(default)] + pub editor: TuiEditorKeymap, + #[serde(default)] + pub pager: TuiPagerKeymap, + #[serde(default)] + pub list: TuiListKeymap, + #[serde(default)] + pub approval: TuiApprovalKeymap, +} + +/// Normalize one user-entered key spec into canonical storage format. +/// +/// The output always orders modifiers as `ctrl-alt-shift-` when present +/// and applies accepted aliases (`escape` -> `esc`, `pageup` -> `page-up`). +/// Inputs that cannot be represented unambiguously are rejected. +/// +/// Normalization happens at config-deserialization time so downstream runtime +/// code only has to parse one spelling for each key. Callers should not bypass +/// this function when accepting user-authored key specs, or otherwise equivalent +/// keys can fail to compare equal in tests, UI hints, and duplicate detection. +fn normalize_keybinding_spec(raw: &str) -> Result { + let lower = raw.trim().to_ascii_lowercase(); + if lower.is_empty() { + return Err( + "keybinding cannot be empty. Use values like `ctrl-a` or `shift-enter`.\n\ +See the Codex keymap documentation for supported actions and examples." + .to_string(), + ); + } + + let segments: Vec<&str> = lower + .split('-') + .filter(|segment| !segment.is_empty()) + .collect(); + if segments.is_empty() { + return Err(format!( + "invalid keybinding `{raw}`. Use values like `ctrl-a`, `shift-enter`, or `page-down`." + )); + } + + let mut modifiers = + BTreeMap::<&str, bool>::from([("ctrl", false), ("alt", false), ("shift", false)]); + let mut key_segments = Vec::new(); + let mut saw_key = false; + + for segment in segments { + let canonical_mod = match segment { + "ctrl" | "control" => Some("ctrl"), + "alt" | "option" => Some("alt"), + "shift" => Some("shift"), + _ => None, + }; + + if !saw_key && let Some(modifier) = canonical_mod { + if modifiers.get(modifier).copied().unwrap_or(false) { + return Err(format!( + "duplicate modifier in keybinding `{raw}`. Use each modifier at most once." + )); + } + modifiers.insert(modifier, true); + continue; + } + + saw_key = true; + key_segments.push(segment); + } + + if key_segments.is_empty() { + return Err(format!( + "missing key in keybinding `{raw}`. Add a key name like `a`, `enter`, or `page-down`." + )); + } + + if key_segments + .iter() + .any(|segment| matches!(*segment, "ctrl" | "control" | "alt" | "option" | "shift")) + { + return Err(format!( + "invalid keybinding `{raw}`: modifiers must come before the key (for example `ctrl-a`)." + )); + } + + let key = normalize_key_name(&key_segments.join("-"), raw)?; + let mut normalized = Vec::new(); + if modifiers.get("ctrl").copied().unwrap_or(false) { + normalized.push("ctrl".to_string()); + } + if modifiers.get("alt").copied().unwrap_or(false) { + normalized.push("alt".to_string()); + } + if modifiers.get("shift").copied().unwrap_or(false) { + normalized.push("shift".to_string()); + } + normalized.push(key); + Ok(normalized.join("-")) +} + +/// Normalize and validate one key name segment. +/// +/// This accepts a constrained key vocabulary to keep runtime parser behavior +/// deterministic across platforms. +fn normalize_key_name(key: &str, original: &str) -> Result { + let alias = match key { + "escape" => "esc", + "return" => "enter", + "spacebar" => "space", + "pgup" | "pageup" => "page-up", + "pgdn" | "pagedown" => "page-down", + "del" => "delete", + other => other, + }; + + if alias.len() == 1 { + let ch = alias.chars().next().unwrap_or_default(); + if ch.is_ascii() && !ch.is_ascii_control() && ch != '-' { + return Ok(alias.to_string()); + } + } + + if matches!( + alias, + "enter" + | "tab" + | "backspace" + | "esc" + | "delete" + | "up" + | "down" + | "left" + | "right" + | "home" + | "end" + | "page-up" + | "page-down" + | "space" + ) { + return Ok(alias.to_string()); + } + + if let Some(number) = alias.strip_prefix('f') + && let Ok(number) = number.parse::() + && (1..=12).contains(&number) + { + return Ok(alias.to_string()); + } + + Err(format!( + "unknown key `{key}` in keybinding `{original}`. \ +Use a printable character (for example `a`), function keys (`f1`-`f12`), \ +or one of: enter, tab, backspace, esc, delete, arrows, home/end, page-up/page-down, space.\n\ +See the Codex keymap documentation for supported actions and examples." + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn misplaced_action_at_keymap_root_is_rejected() { + // Actions placed directly under [tui.keymap] instead of a context + // sub-table (e.g. [tui.keymap.global]) must produce a parse error, + // not be silently ignored. + let toml_input = r#" + open_transcript = "ctrl-s" + "#; + let result = toml::from_str::(toml_input); + assert!( + result.is_err(), + "expected error for action at keymap root, got: {result:?}" + ); + } + + #[test] + fn misspelled_action_under_context_is_rejected() { + let toml_input = r#" + [global] + open_transcrip = "ctrl-x" + "#; + let err = toml::from_str::(toml_input) + .expect_err("expected unknown action under context"); + assert!( + err.to_string().contains("open_transcrip"), + "expected error to mention misspelled field, got: {err}" + ); + } + + #[test] + fn removed_backtrack_actions_are_rejected() { + for (context, action) in [ + ("global", "edit_previous_message"), + ("global", "confirm_edit_previous_message"), + ("chat", "edit_previous_message"), + ("chat", "confirm_edit_previous_message"), + ("pager", "edit_previous_message"), + ("pager", "edit_next_message"), + ("pager", "confirm_edit_message"), + ] { + let toml_input = format!( + r#" + [{context}] + {action} = "ctrl-x" + "# + ); + let err = toml::from_str::(&toml_input) + .expect_err("expected removed backtrack action to be rejected"); + assert!( + err.to_string().contains(action), + "expected error to mention removed field {action}, got: {err}" + ); + } + } + + #[test] + fn action_under_global_context_is_accepted() { + let toml_input = r#" + [global] + open_transcript = "ctrl-s" + "#; + let keymap: TuiKeymap = toml::from_str(toml_input).expect("valid config"); + assert!(keymap.global.open_transcript.is_some()); + } +} diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index b1adc1fd73..5f2877de26 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -28,6 +28,17 @@ use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; +pub use crate::tui_keymap::KeybindingSpec; +pub use crate::tui_keymap::KeybindingsSpec; +pub use crate::tui_keymap::TuiApprovalKeymap; +pub use crate::tui_keymap::TuiChatKeymap; +pub use crate::tui_keymap::TuiComposerKeymap; +pub use crate::tui_keymap::TuiEditorKeymap; +pub use crate::tui_keymap::TuiGlobalKeymap; +pub use crate::tui_keymap::TuiKeymap; +pub use crate::tui_keymap::TuiListKeymap; +pub use crate::tui_keymap::TuiPagerKeymap; + pub const DEFAULT_OTEL_ENVIRONMENT: &str = "dev"; pub const DEFAULT_MEMORIES_MAX_ROLLOUTS_PER_STARTUP: usize = 2; pub const DEFAULT_MEMORIES_MAX_ROLLOUT_AGE_DAYS: i64 = 10; @@ -598,6 +609,13 @@ pub struct Tui { #[serde(default)] pub theme: Option, + /// Keybinding overrides for the TUI. + /// + /// This supports rebinding selected actions globally and by context. + /// Context bindings take precedence over `global` bindings. + #[serde(default)] + pub keymap: TuiKeymap, + /// Startup tooltip availability NUX state persisted by the TUI. #[serde(default)] pub model_availability_nux: ModelAvailabilityNuxConfig, diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 9cb23a4434..70e49d34ec 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -967,6 +967,20 @@ } ] }, + "KeybindingsSpec": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "description": "One action binding value in config.\n\nThis accepts either:\n\n1. A single key spec string (`\"ctrl-a\"`). 2. A list of key spec strings (`[\"ctrl-a\", \"alt-a\"]`).\n\nAn empty list explicitly unbinds the action in that scope. Because an explicit empty list is still a configured value, runtime resolution must not fall through to global or built-in defaults for that action." + }, "MarketplaceConfig": { "additionalProperties": false, "properties": { @@ -2224,6 +2238,83 @@ "description": "Enable animations (welcome screen, shimmer effects, spinners). Defaults to `true`.", "type": "boolean" }, + "keymap": { + "allOf": [ + { + "$ref": "#/definitions/TuiKeymap" + } + ], + "default": { + "approval": { + "approve": null, + "approve_for_prefix": null, + "approve_for_session": null, + "cancel": null, + "decline": null, + "deny": null, + "open_fullscreen": null, + "open_thread": null + }, + "chat": { + "decrease_reasoning_effort": null, + "edit_queued_message": null, + "increase_reasoning_effort": null + }, + "composer": { + "history_search_next": null, + "history_search_previous": null, + "queue": null, + "submit": null, + "toggle_shortcuts": null + }, + "editor": { + "delete_backward": null, + "delete_backward_word": null, + "delete_forward": null, + "delete_forward_word": null, + "insert_newline": null, + "kill_line_end": null, + "kill_line_start": null, + "move_down": null, + "move_left": null, + "move_line_end": null, + "move_line_start": null, + "move_right": null, + "move_up": null, + "move_word_left": null, + "move_word_right": null, + "yank": null + }, + "global": { + "clear_terminal": null, + "copy": null, + "open_external_editor": null, + "open_transcript": null, + "queue": null, + "submit": null, + "toggle_shortcuts": null + }, + "list": { + "accept": null, + "cancel": null, + "move_down": null, + "move_up": null + }, + "pager": { + "close": null, + "close_transcript": null, + "half_page_down": null, + "half_page_up": null, + "jump_bottom": null, + "jump_top": null, + "page_down": null, + "page_up": null, + "scroll_down": null, + "scroll_up": null + } + }, + "description": "Keybinding overrides for the TUI.\n\nThis supports rebinding selected actions globally and by context. Context bindings take precedence over `global` bindings." + }, "model_availability_nux": { "allOf": [ { @@ -2296,6 +2387,602 @@ }, "type": "object" }, + "TuiApprovalKeymap": { + "additionalProperties": false, + "description": "Approval overlay keybindings.", + "properties": { + "approve": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Approve the primary option." + }, + "approve_for_prefix": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Approve with exec-policy prefix when that option exists." + }, + "approve_for_session": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Approve for session when that option exists." + }, + "cancel": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Cancel an elicitation request." + }, + "decline": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Decline and provide corrective guidance." + }, + "deny": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Deny without providing follow-up guidance." + }, + "open_fullscreen": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Open the full-screen approval details view." + }, + "open_thread": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Open the thread that requested approval when shown from another thread." + } + }, + "type": "object" + }, + "TuiChatKeymap": { + "additionalProperties": false, + "description": "Chat context keybindings.", + "properties": { + "decrease_reasoning_effort": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Decrease the active reasoning effort." + }, + "edit_queued_message": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Edit the most recently queued message." + }, + "increase_reasoning_effort": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Increase the active reasoning effort." + } + }, + "type": "object" + }, + "TuiComposerKeymap": { + "additionalProperties": false, + "description": "Composer context keybindings. These override corresponding `global` actions.", + "properties": { + "history_search_next": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move to the next match in reverse history search." + }, + "history_search_previous": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Open reverse history search or move to the previous match." + }, + "queue": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Queue the current composer draft while a task is running." + }, + "submit": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Submit the current composer draft." + }, + "toggle_shortcuts": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Toggle the composer shortcut overlay." + } + }, + "type": "object" + }, + "TuiEditorKeymap": { + "additionalProperties": false, + "description": "Editor context keybindings for text editing inside text areas.", + "properties": { + "delete_backward": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Delete one grapheme to the left." + }, + "delete_backward_word": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Delete the previous word." + }, + "delete_forward": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Delete one grapheme to the right." + }, + "delete_forward_word": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Delete the next word." + }, + "insert_newline": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Insert a newline in the editor." + }, + "kill_line_end": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Kill text from cursor to line end." + }, + "kill_line_start": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Kill text from cursor to line start." + }, + "move_down": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move cursor down one visual line." + }, + "move_left": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move cursor left by one grapheme." + }, + "move_line_end": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move cursor to end of line." + }, + "move_line_start": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move cursor to beginning of line." + }, + "move_right": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move cursor right by one grapheme." + }, + "move_up": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move cursor up one visual line." + }, + "move_word_left": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move cursor to beginning of previous word." + }, + "move_word_right": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move cursor to end of next word." + }, + "yank": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Yank the kill buffer." + } + }, + "type": "object" + }, + "TuiGlobalKeymap": { + "additionalProperties": false, + "description": "Global keybindings. These are used when a context does not define an override.", + "properties": { + "clear_terminal": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Clear the terminal UI." + }, + "copy": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Copy the last agent response to the clipboard." + }, + "open_external_editor": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Open the external editor for the current draft." + }, + "open_transcript": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Open the transcript overlay." + }, + "queue": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Queue the current composer draft while a task is running." + }, + "submit": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Submit the current composer draft." + }, + "toggle_shortcuts": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Toggle the composer shortcut overlay." + } + }, + "type": "object" + }, + "TuiKeymap": { + "additionalProperties": false, + "description": "Raw keymap configuration from `[tui.keymap]`.\n\nEach context contains action-level overrides. Missing actions inherit from built-in defaults, and selected chat/composer actions can fall back through `global` during runtime resolution.\n\nThis type is intentionally a persistence shape, not the structure used by input handlers. Runtime consumers should resolve it into `RuntimeKeymap` first so precedence, empty-list unbinding, and duplicate-key validation are applied consistently.", + "properties": { + "approval": { + "allOf": [ + { + "$ref": "#/definitions/TuiApprovalKeymap" + } + ], + "default": { + "approve": null, + "approve_for_prefix": null, + "approve_for_session": null, + "cancel": null, + "decline": null, + "deny": null, + "open_fullscreen": null, + "open_thread": null + } + }, + "chat": { + "allOf": [ + { + "$ref": "#/definitions/TuiChatKeymap" + } + ], + "default": { + "decrease_reasoning_effort": null, + "edit_queued_message": null, + "increase_reasoning_effort": null + } + }, + "composer": { + "allOf": [ + { + "$ref": "#/definitions/TuiComposerKeymap" + } + ], + "default": { + "history_search_next": null, + "history_search_previous": null, + "queue": null, + "submit": null, + "toggle_shortcuts": null + } + }, + "editor": { + "allOf": [ + { + "$ref": "#/definitions/TuiEditorKeymap" + } + ], + "default": { + "delete_backward": null, + "delete_backward_word": null, + "delete_forward": null, + "delete_forward_word": null, + "insert_newline": null, + "kill_line_end": null, + "kill_line_start": null, + "move_down": null, + "move_left": null, + "move_line_end": null, + "move_line_start": null, + "move_right": null, + "move_up": null, + "move_word_left": null, + "move_word_right": null, + "yank": null + } + }, + "global": { + "allOf": [ + { + "$ref": "#/definitions/TuiGlobalKeymap" + } + ], + "default": { + "clear_terminal": null, + "copy": null, + "open_external_editor": null, + "open_transcript": null, + "queue": null, + "submit": null, + "toggle_shortcuts": null + } + }, + "list": { + "allOf": [ + { + "$ref": "#/definitions/TuiListKeymap" + } + ], + "default": { + "accept": null, + "cancel": null, + "move_down": null, + "move_up": null + } + }, + "pager": { + "allOf": [ + { + "$ref": "#/definitions/TuiPagerKeymap" + } + ], + "default": { + "close": null, + "close_transcript": null, + "half_page_down": null, + "half_page_up": null, + "jump_bottom": null, + "jump_top": null, + "page_down": null, + "page_up": null, + "scroll_down": null, + "scroll_up": null + } + } + }, + "type": "object" + }, + "TuiListKeymap": { + "additionalProperties": false, + "description": "List selection context keybindings for popup-style selectable lists.", + "properties": { + "accept": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Accept current selection." + }, + "cancel": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Cancel and close selection view." + }, + "move_down": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move list selection down." + }, + "move_up": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move list selection up." + } + }, + "type": "object" + }, + "TuiPagerKeymap": { + "additionalProperties": false, + "description": "Pager context keybindings for transcript and static overlays.", + "properties": { + "close": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Close the pager overlay." + }, + "close_transcript": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Close the transcript overlay via its dedicated toggle key." + }, + "half_page_down": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Scroll down by half a page." + }, + "half_page_up": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Scroll up by half a page." + }, + "jump_bottom": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Jump to the end." + }, + "jump_top": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Jump to the beginning." + }, + "page_down": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Scroll down by one page." + }, + "page_up": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Scroll up by one page." + }, + "scroll_down": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Scroll down by one row." + }, + "scroll_up": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Scroll up by one row." + } + }, + "type": "object" + }, "UriBasedFileOpener": { "oneOf": [ { diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 0e51ac37e9..17246ae774 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -48,6 +48,7 @@ use codex_config::types::SandboxWorkspaceWrite; use codex_config::types::SkillsConfig; use codex_config::types::ToolSuggestDiscoverableType; use codex_config::types::Tui; +use codex_config::types::TuiKeymap; use codex_config::types::TuiNotificationSettings; use codex_exec_server::LOCAL_FS; use codex_features::Feature; @@ -547,6 +548,7 @@ fn config_toml_deserializes_model_availability_nux() { status_line: None, terminal_title: None, theme: None, + keymap: TuiKeymap::default(), model_availability_nux: ModelAvailabilityNuxConfig { shown_count: HashMap::from([ ("gpt-bar".to_string(), 4), @@ -1586,6 +1588,7 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() { status_line: None, terminal_title: None, theme: None, + keymap: TuiKeymap::default(), model_availability_nux: ModelAvailabilityNuxConfig::default(), terminal_resize_reflow_max_rows: None, } @@ -5636,6 +5639,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { tui_status_line: None, tui_terminal_title: None, tui_theme: None, + tui_keymap: TuiKeymap::default(), otel: OtelConfig::default(), }, o3_profile_config @@ -5829,6 +5833,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { tui_status_line: None, tui_terminal_title: None, tui_theme: None, + tui_keymap: TuiKeymap::default(), otel: OtelConfig::default(), }; @@ -5976,6 +5981,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { tui_status_line: None, tui_terminal_title: None, tui_theme: None, + tui_keymap: TuiKeymap::default(), otel: OtelConfig::default(), }; @@ -6108,6 +6114,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { tui_status_line: None, tui_terminal_title: None, tui_theme: None, + tui_keymap: TuiKeymap::default(), otel: OtelConfig::default(), }; diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index e49dc9dc08..f86e0d925b 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -113,6 +113,45 @@ pub fn terminal_title_items_edit(items: &[String]) -> ConfigEdit { } } +fn keymap_binding_value(keys: &[String]) -> TomlItem { + if let [key] = keys { + value(key.to_string()) + } else { + let array = keys.iter().cloned().collect::(); + TomlItem::Value(array.into()) + } +} + +/// Produces a config edit that replaces one root-level TUI keymap binding list. +pub fn keymap_bindings_edit(context: &str, action: &str, keys: &[String]) -> ConfigEdit { + ConfigEdit::SetPath { + segments: vec![ + "tui".to_string(), + "keymap".to_string(), + context.to_string(), + action.to_string(), + ], + value: keymap_binding_value(keys), + } +} + +/// Produces a config edit that replaces one root-level TUI keymap binding. +pub fn keymap_binding_edit(context: &str, action: &str, key: &str) -> ConfigEdit { + keymap_bindings_edit(context, action, &[key.to_string()]) +} + +/// Produces a config edit that removes one root-level TUI keymap binding. +pub fn keymap_binding_clear_edit(context: &str, action: &str) -> ConfigEdit { + ConfigEdit::ClearPath { + segments: vec![ + "tui".to_string(), + "keymap".to_string(), + context.to_string(), + action.to_string(), + ], + } +} + pub fn model_availability_nux_count_edits(shown_count: &HashMap) -> Vec { let mut shown_count_entries: Vec<_> = shown_count.iter().collect(); shown_count_entries.sort_unstable_by(|(left, _), (right, _)| left.cmp(right)); diff --git a/codex-rs/core/src/config/edit_tests.rs b/codex-rs/core/src/config/edit_tests.rs index ec81c7c06d..376632a93a 100644 --- a/codex-rs/core/src/config/edit_tests.rs +++ b/codex-rs/core/src/config/edit_tests.rs @@ -48,6 +48,171 @@ fn builder_with_edits_applies_custom_paths() { assert_eq!(contents, "enabled = true\n"); } +#[test] +fn keymap_binding_edit_writes_root_action_binding() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + + ConfigEditsBuilder::new(codex_home) + .with_edits([keymap_binding_edit("composer", "submit", "ctrl-enter")]) + .apply_blocking() + .expect("persist"); + + let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let expected = r#"[tui.keymap.composer] +submit = "ctrl-enter" +"#; + assert_eq!(contents, expected); +} + +#[test] +fn keymap_bindings_edit_writes_single_binding_as_string() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + + ConfigEditsBuilder::new(codex_home) + .with_edits([keymap_bindings_edit( + "composer", + "submit", + &["ctrl-enter".to_string()], + )]) + .apply_blocking() + .expect("persist"); + + let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let expected = r#"[tui.keymap.composer] +submit = "ctrl-enter" +"#; + assert_eq!(contents, expected); +} + +#[test] +fn keymap_bindings_edit_writes_multiple_bindings_as_array() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + + ConfigEditsBuilder::new(codex_home) + .with_edits([keymap_bindings_edit( + "composer", + "submit", + &["enter".to_string(), "ctrl-enter".to_string()], + )]) + .apply_blocking() + .expect("persist"); + + let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let value: TomlValue = toml::from_str(&raw).expect("parse config"); + + assert_eq!( + value + .get("tui") + .and_then(|value| value.get("keymap")) + .and_then(|value| value.get("composer")) + .and_then(|value| value.get("submit")) + .and_then(TomlValue::as_array) + .map(|values| { + values + .iter() + .filter_map(TomlValue::as_str) + .collect::>() + }), + Some(vec!["enter", "ctrl-enter"]) + ); +} + +#[test] +fn keymap_binding_edit_replaces_existing_binding_without_touching_profile() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + std::fs::write( + codex_home.join(CONFIG_TOML_FILE), + r#"profile = "team" + +[tui.keymap.composer] +submit = "enter" + +[profiles.team.tui.keymap.composer] +submit = "shift-enter" +"#, + ) + .expect("seed config"); + + ConfigEditsBuilder::new(codex_home) + .with_edits([keymap_binding_edit("composer", "submit", "ctrl-enter")]) + .apply_blocking() + .expect("persist"); + + let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let value: TomlValue = toml::from_str(&raw).expect("parse config"); + + assert_eq!( + value + .get("tui") + .and_then(|value| value.get("keymap")) + .and_then(|value| value.get("composer")) + .and_then(|value| value.get("submit")) + .and_then(TomlValue::as_str), + Some("ctrl-enter") + ); + assert_eq!( + value + .get("profiles") + .and_then(|value| value.get("team")) + .and_then(|value| value.get("tui")) + .and_then(|value| value.get("keymap")) + .and_then(|value| value.get("composer")) + .and_then(|value| value.get("submit")) + .and_then(TomlValue::as_str), + Some("shift-enter") + ); +} + +#[test] +fn keymap_binding_clear_edit_removes_root_action_binding_without_touching_profile() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + std::fs::write( + codex_home.join(CONFIG_TOML_FILE), + r#"profile = "team" + +[tui.keymap.composer] +submit = "enter" + +[profiles.team.tui.keymap.composer] +submit = "shift-enter" +"#, + ) + .expect("seed config"); + + ConfigEditsBuilder::new(codex_home) + .with_edits([keymap_binding_clear_edit("composer", "submit")]) + .apply_blocking() + .expect("persist"); + + let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let value: TomlValue = toml::from_str(&raw).expect("parse config"); + + assert_eq!( + value + .get("tui") + .and_then(|value| value.get("keymap")) + .and_then(|value| value.get("composer")) + .and_then(|value| value.get("submit")), + None + ); + assert_eq!( + value + .get("profiles") + .and_then(|value| value.get("team")) + .and_then(|value| value.get("tui")) + .and_then(|value| value.get("keymap")) + .and_then(|value| value.get("composer")) + .and_then(|value| value.get("submit")) + .and_then(TomlValue::as_str), + Some("shift-enter") + ); +} + #[test] fn set_model_availability_nux_count_writes_shown_count() { let tmp = tempdir().expect("tmpdir"); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index f8a079b1f2..ef5ba7cadc 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -47,6 +47,7 @@ use codex_config::types::OtelConfigToml; use codex_config::types::OtelExporterKind; use codex_config::types::ToolSuggestConfig; use codex_config::types::ToolSuggestDiscoverable; +use codex_config::types::TuiKeymap; use codex_config::types::TuiNotificationSettings; use codex_config::types::UriBasedFileOpener; use codex_config::types::WindowsSandboxModeToml; @@ -467,7 +468,6 @@ pub struct Config { /// - `always`: Always use alternate screen (original behavior). /// - `never`: Never use alternate screen (inline mode, preserves scrollback). pub tui_alternate_screen: AltScreenMode, - /// Ordered list of status line item identifiers for the TUI. /// /// When unset, the TUI defaults to: `model-with-reasoning` and `current-dir`. @@ -486,6 +486,15 @@ pub struct Config { /// Terminal resize-reflow tuning knobs. pub terminal_resize_reflow: TerminalResizeReflowConfig, + /// Keybinding overrides for the TUI. + /// + /// Precedence is: + /// + /// 1. context table (`tui.keymap.chat`, `tui.keymap.composer`, etc.) + /// 2. `tui.keymap.global` + /// 3. built-in defaults + pub tui_keymap: TuiKeymap, + /// The absolute directory that should be treated as the current working /// directory for the session. All relative paths inside the business-logic /// layer are resolved against this path. @@ -2682,6 +2691,11 @@ impl Config { tui_terminal_title: cfg.tui.as_ref().and_then(|t| t.terminal_title.clone()), tui_theme: cfg.tui.as_ref().and_then(|t| t.theme.clone()), terminal_resize_reflow, + tui_keymap: cfg + .tui + .as_ref() + .map(|t| t.keymap.clone()) + .unwrap_or_default(), otel: { let t: OtelConfigToml = cfg.otel.unwrap_or_default(); let log_user_prompt = t.log_user_prompt.unwrap_or(false); diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index bffa46e478..e454c83c5b 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -41,6 +41,8 @@ use crate::history_cell; use crate::history_cell::HistoryCell; #[cfg(not(debug_assertions))] use crate::history_cell::UpdateAvailableHistoryCell; +use crate::key_hint::KeyBindingListExt; +use crate::keymap::RuntimeKeymap; use crate::legacy_core::append_message_history_entry; use crate::legacy_core::config::Config; use crate::legacy_core::config::ConfigBuilder; @@ -520,6 +522,7 @@ pub(crate) struct App { initial_history_replay_buffer: Option, pub(crate) enhanced_keys_supported: bool, + pub(crate) keymap: RuntimeKeymap, /// Controls the animation thread that sends CommitTick events. pub(crate) commit_anim_running: Arc, @@ -883,6 +886,13 @@ impl App { .maybe_prompt_windows_sandbox_enable(should_prompt_windows_sandbox_nux_at_startup); let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone()); + let runtime_keymap = RuntimeKeymap::from_config(&config.tui_keymap).map_err(|err| { + color_eyre::eyre::eyre!( + "Invalid `tui.keymap` configuration: {err}\n\ +Fix the config and retry.\n\ +See the Codex keymap documentation for supported actions and examples." + ) + })?; #[cfg(not(debug_assertions))] let upgrade_version = crate::updates::get_upgrade_version(&config); @@ -899,6 +909,7 @@ impl App { runtime_sandbox_policy_override: None, file_search, enhanced_keys_supported, + keymap: runtime_keymap, transcript_cells: Vec::new(), overlay: None, deferred_history_lines: Vec::new(), diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index ac5930f93c..d249c40c8a 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -340,6 +340,7 @@ impl App { self.overlay = Some(Overlay::new_static_with_lines( pager_lines, "D I F F".to_string(), + self.keymap.pager.clone(), )); tui.frame_requester().schedule_frame(); } @@ -1590,6 +1591,7 @@ impl App { self.overlay = Some(Overlay::new_static_with_renderables( vec![diff_summary.into()], "P A T C H".to_string(), + self.keymap.pager.clone(), )); } ApprovalRequest::Exec { command, .. } => { @@ -1599,6 +1601,7 @@ impl App { self.overlay = Some(Overlay::new_static_with_lines( full_cmd_lines, "E X E C".to_string(), + self.keymap.pager.clone(), )); } ApprovalRequest::Permissions { @@ -1623,6 +1626,7 @@ impl App { self.overlay = Some(Overlay::new_static_with_renderables( vec![Box::new(Paragraph::new(lines).wrap(Wrap { trim: false }))], "P E R M I S S I O N S".to_string(), + self.keymap.pager.clone(), )); } ApprovalRequest::McpElicitation { @@ -1640,6 +1644,7 @@ impl App { self.overlay = Some(Overlay::new_static_with_renderables( vec![Box::new(paragraph)], "E L I C I T A T I O N".to_string(), + self.keymap.pager.clone(), )); } }, @@ -1736,10 +1741,154 @@ impl App { } } } + AppEvent::OpenKeymapActionMenu { context, action } => { + self.chat_widget + .open_keymap_action_menu(context, action, &self.keymap); + } + AppEvent::OpenKeymapReplaceBindingMenu { context, action } => { + self.chat_widget + .open_keymap_replace_binding_menu(context, action, &self.keymap); + } + AppEvent::OpenKeymapCapture { + context, + action, + intent, + } => { + self.chat_widget + .open_keymap_capture(context, action, intent, &self.keymap); + } + AppEvent::KeymapCaptured { + context, + action, + key, + intent, + } => { + self.apply_keymap_capture(context, action, key, intent) + .await; + } + AppEvent::KeymapCleared { context, action } => { + self.apply_keymap_clear(context, action).await; + } } Ok(AppRunControl::Continue) } + async fn apply_keymap_capture( + &mut self, + context: String, + action: String, + key: String, + intent: crate::app_event::KeymapEditIntent, + ) { + let outcome = match crate::keymap_setup::keymap_with_edit( + &self.config.tui_keymap, + &self.keymap, + &context, + &action, + &key, + &intent, + ) { + Ok(outcome) => outcome, + Err(err) => { + self.chat_widget.add_error_message(err); + return; + } + }; + let (keymap_config, bindings, message) = match outcome { + crate::keymap_setup::KeymapEditOutcome::Updated { + keymap_config, + bindings, + message, + } => (*keymap_config, bindings, message), + crate::keymap_setup::KeymapEditOutcome::Unchanged { message } => { + self.chat_widget.add_info_message(message, /*hint*/ None); + return; + } + }; + + let runtime_keymap = match RuntimeKeymap::from_config(&keymap_config) { + Ok(runtime_keymap) => runtime_keymap, + Err(err) => { + let params = crate::keymap_setup::build_keymap_conflict_params( + context, action, key, intent, err, + ); + self.chat_widget.show_selection_view(params); + return; + } + }; + + let edit = + crate::legacy_core::config::edit::keymap_bindings_edit(&context, &action, &bindings); + match ConfigEditsBuilder::new(&self.config.codex_home) + .with_edits([edit]) + .apply() + .await + { + Ok(()) => { + self.config.tui_keymap = keymap_config.clone(); + self.keymap = runtime_keymap.clone(); + self.chat_widget + .apply_keymap_update(keymap_config, &runtime_keymap); + self.chat_widget + .return_to_keymap_picker(&context, &action, &runtime_keymap); + self.chat_widget.add_info_message(message, /*hint*/ None); + } + Err(err) => { + tracing::error!(error = %err, "failed to persist keymap binding"); + self.chat_widget + .add_error_message(format!("Failed to save shortcut: {err}")); + } + } + } + + async fn apply_keymap_clear(&mut self, context: String, action: String) { + let keymap_config = match crate::keymap_setup::keymap_without_custom_binding( + &self.config.tui_keymap, + &context, + &action, + ) { + Ok(keymap_config) => keymap_config, + Err(err) => { + self.chat_widget.add_error_message(err); + return; + } + }; + + let runtime_keymap = match RuntimeKeymap::from_config(&keymap_config) { + Ok(runtime_keymap) => runtime_keymap, + Err(err) => { + self.chat_widget + .add_error_message(format!("Failed to refresh shortcuts: {err}")); + return; + } + }; + + let edit = crate::legacy_core::config::edit::keymap_binding_clear_edit(&context, &action); + match ConfigEditsBuilder::new(&self.config.codex_home) + .with_edits([edit]) + .apply() + .await + { + Ok(()) => { + self.config.tui_keymap = keymap_config.clone(); + self.keymap = runtime_keymap.clone(); + self.chat_widget + .apply_keymap_update(keymap_config, &runtime_keymap); + self.chat_widget + .return_to_keymap_picker(&context, &action, &runtime_keymap); + self.chat_widget.add_info_message( + format!("Removed custom shortcut for `{context}.{action}`."), + /*hint*/ None, + ); + } + Err(err) => { + tracing::error!(error = %err, "failed to clear keymap binding"); + self.chat_widget + .add_error_message(format!("Failed to remove shortcut: {err}")); + } + } + } + pub(super) async fn handle_exit_mode( &mut self, app_server: &mut AppServerSession, diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index 36d4c5611b..045f84de17 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -122,24 +122,46 @@ impl App { return; } - match key_event { - KeyEvent { - code: KeyCode::Char('t'), - modifiers: KeyModifiers::CONTROL, - kind: KeyEventKind::Press, - .. - } => { - // Enter alternate screen and set viewport to full size. - let _ = tui.enter_alt_screen(); - self.overlay = Some(Overlay::new_transcript(self.transcript_cells.clone())); - tui.frame_requester().schedule_frame(); + if self.keymap.app.open_transcript.is_pressed(key_event) { + // Enter alternate screen and set viewport to full size. + let _ = tui.enter_alt_screen(); + self.overlay = Some(Overlay::new_transcript( + self.transcript_cells.clone(), + self.keymap.pager.clone(), + )); + tui.frame_requester().schedule_frame(); + return; + } + + if self.keymap.app.open_external_editor.is_pressed(key_event) { + // Only launch the external editor if there is no overlay and the bottom pane is not in use. + // Note that it can be launched while a task is running to enable editing while the previous turn is ongoing. + if self.overlay.is_none() + && self.chat_widget.can_launch_external_editor() + && self.chat_widget.external_editor_state() == ExternalEditorState::Closed + { + self.request_external_editor_launch(tui); } - KeyEvent { - code: KeyCode::Char('l'), - modifiers: KeyModifiers::CONTROL, - kind: KeyEventKind::Press, - .. - } => { + return; + } + + if matches!(key_event.code, KeyCode::Esc) + && matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) + { + // Esc primes/advances backtracking only in normal (not working) mode + // with the composer focused and empty. In any other state, forward + // Esc so the active UI (e.g. status indicator, modals, popups) + // handles it. + if self.chat_widget.is_normal_backtrack_mode() && self.chat_widget.composer_is_empty() { + self.handle_backtrack_esc_key(tui); + } else { + self.chat_widget.handle_key_event(key_event); + } + return; + } + + match key_event { + _ if self.keymap.app.clear_terminal.is_pressed(key_event) => { if !self.chat_widget.can_run_ctrl_l_clear_now() { return; } @@ -153,38 +175,6 @@ impl App { tui.frame_requester().schedule_frame(); } } - KeyEvent { - code: KeyCode::Char('g'), - modifiers: KeyModifiers::CONTROL, - kind: KeyEventKind::Press, - .. - } => { - // Only launch the external editor if there is no overlay and the bottom pane is not in use. - // Note that it can be launched while a task is running to enable editing while the previous turn is ongoing. - if self.overlay.is_none() - && self.chat_widget.can_launch_external_editor() - && self.chat_widget.external_editor_state() == ExternalEditorState::Closed - { - self.request_external_editor_launch(tui); - } - } - // Esc primes/advances backtracking only in normal (not working) mode - // with the composer focused and empty. In any other state, forward - // Esc so the active UI (e.g. status indicator, modals, popups) - // handles it. - KeyEvent { - code: KeyCode::Esc, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - } => { - if self.chat_widget.is_normal_backtrack_mode() - && self.chat_widget.composer_is_empty() - { - self.handle_backtrack_esc_key(tui); - } else { - self.chat_widget.handle_key_event(key_event); - } - } // Enter confirms backtrack when primed + count > 0. Otherwise pass to widget. KeyEvent { code: KeyCode::Enter, diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index 29b7dede05..0616d94dcd 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -33,6 +33,7 @@ pub(super) async fn make_test_app() -> App { transcript_reflow: TranscriptReflowState::default(), initial_history_replay_buffer: None, enhanced_keys_supported: false, + keymap: crate::keymap::RuntimeKeymap::defaults(), commit_anim_running: Arc::new(AtomicBool::new(false)), status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 9477030bc5..a543e4a35d 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -3696,6 +3696,7 @@ async fn make_test_app() -> App { transcript_reflow: TranscriptReflowState::default(), initial_history_replay_buffer: None, enhanced_keys_supported: false, + keymap: crate::keymap::RuntimeKeymap::defaults(), commit_anim_running: Arc::new(AtomicBool::new(false)), status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), @@ -3755,6 +3756,7 @@ async fn make_test_app_with_channels() -> ( transcript_reflow: TranscriptReflowState::default(), initial_history_replay_buffer: None, enhanced_keys_supported: false, + keymap: crate::keymap::RuntimeKeymap::defaults(), commit_anim_running: Arc::new(AtomicBool::new(false)), status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), @@ -4754,7 +4756,10 @@ async fn queued_rollback_syncs_overlay_and_clears_deferred_history() { /*is_first_line*/ false, )) as Arc, ]; - app.overlay = Some(Overlay::new_transcript(app.transcript_cells.clone())); + app.overlay = Some(Overlay::new_transcript( + app.transcript_cells.clone(), + app.keymap.pager.clone(), + )); app.deferred_history_lines = vec![Line::from("stale buffered line")]; app.backtrack.overlay_preview_active = true; app.backtrack.nth_user_message = 1; @@ -4978,7 +4983,10 @@ async fn clear_only_ui_reset_preserves_chat_session_state() { local_image_paths: Vec::new(), remote_image_urls: Vec::new(), }) as Arc]; - app.overlay = Some(Overlay::new_transcript(app.transcript_cells.clone())); + app.overlay = Some(Overlay::new_transcript( + app.transcript_cells.clone(), + crate::keymap::RuntimeKeymap::defaults().pager, + )); app.deferred_history_lines = vec![Line::from("stale buffered line")]; app.has_emitted_history_lines = true; app.backtrack.primed = true; diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 2afcaa3b53..b9770b5c5e 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -146,7 +146,6 @@ impl App { self.overlay_confirm_backtrack(tui); Ok(true) } - // Catchall: forward any other events to the overlay widget. _ => { self.overlay_forward_event(tui, event)?; Ok(true) @@ -233,7 +232,10 @@ impl App { /// Open transcript overlay (enters alternate screen and shows full transcript). pub(crate) fn open_transcript_overlay(&mut self, tui: &mut tui::Tui) { let _ = tui.enter_alt_screen(); - self.overlay = Some(Overlay::new_transcript(self.transcript_cells.clone())); + self.overlay = Some(Overlay::new_transcript( + self.transcript_cells.clone(), + self.keymap.pager.clone(), + )); tui.frame_requester().schedule_frame(); } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index e6ce8e9224..b30b504bb6 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -107,6 +107,13 @@ pub(crate) enum RateLimitRefreshOrigin { StatusCommand { request_id: u64 }, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum KeymapEditIntent { + ReplaceAll, + AddAlternate, + ReplaceOne { old_key: String }, +} + #[allow(clippy::large_enum_variant)] #[derive(Debug)] pub(crate) enum AppEvent { @@ -740,6 +747,39 @@ pub(crate) enum AppEvent { SyntaxThemeSelected { name: String, }, + + /// Open set/remove actions for the selected keymap action. + OpenKeymapActionMenu { + context: String, + action: String, + }, + + /// Open binding selection before replacing one binding for an action. + OpenKeymapReplaceBindingMenu { + context: String, + action: String, + }, + + /// Open key capture for the selected keymap action. + OpenKeymapCapture { + context: String, + action: String, + intent: KeymapEditIntent, + }, + + /// Apply a captured key to the selected keymap action. + KeymapCaptured { + context: String, + action: String, + key: String, + intent: KeymapEditIntent, + }, + + /// Remove the custom root binding for the selected keymap action. + KeymapCleared { + context: String, + action: String, + }, } #[derive(Debug)] diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index debcbdac1a..123bd9e4ec 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -1,3 +1,16 @@ +//! Approval modal rendering and decision routing for high-risk operations. +//! +//! This module converts agent approval requests (exec/apply-patch/MCP +//! elicitation) into a list-selection view with action-specific options and +//! shortcuts. It owns two important contracts: +//! +//! 1. Selection always emits an explicit decision event back to the app. +//! 2. MCP elicitation keeps `Esc` mapped to `Cancel`, even with custom +//! keybindings, so dismissal never silently becomes "continue without info". +//! +//! This module does not evaluate whether an action is safe to run; it only +//! presents choices and routes user decisions. + use std::collections::HashMap; use std::path::PathBuf; @@ -9,10 +22,15 @@ use crate::bottom_pane::CancellationEvent; use crate::bottom_pane::list_selection_view::ListSelectionView; use crate::bottom_pane::list_selection_view::SelectionItem; use crate::bottom_pane::list_selection_view::SelectionViewParams; +use crate::bottom_pane::popup_consts::accept_cancel_hint_line; use crate::exec_command::strip_bash_lc_and_escape; use crate::history_cell; use crate::key_hint; use crate::key_hint::KeyBinding; +use crate::key_hint::KeyBindingListExt; +use crate::keymap::ApprovalKeymap; +use crate::keymap::ListKeymap; +use crate::keymap::primary_binding; use crate::render::highlight::highlight_bash_to_lines; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; @@ -142,19 +160,29 @@ pub(crate) struct ApprovalOverlay { current_complete: bool, done: bool, features: Features, + approval_keymap: ApprovalKeymap, + list_keymap: ListKeymap, } impl ApprovalOverlay { - pub fn new(request: ApprovalRequest, app_event_tx: AppEventSender, features: Features) -> Self { + pub fn new( + request: ApprovalRequest, + app_event_tx: AppEventSender, + features: Features, + approval_keymap: ApprovalKeymap, + list_keymap: ListKeymap, + ) -> Self { let mut view = Self { current_request: None, queue: Vec::new(), app_event_tx: app_event_tx.clone(), - list: ListSelectionView::new(Default::default(), app_event_tx), + list: ListSelectionView::new(Default::default(), app_event_tx, list_keymap.clone()), options: Vec::new(), current_complete: false, done: false, features, + approval_keymap, + list_keymap, }; view.set_current(request); view @@ -184,16 +212,25 @@ impl ApprovalOverlay { fn set_current(&mut self, request: ApprovalRequest) { self.current_complete = false; let header = build_header(&request); - let (options, params) = Self::build_options(&request, header, &self.features); + let (options, params) = Self::build_options( + &request, + header, + &self.features, + &self.approval_keymap, + &self.list_keymap, + ); self.current_request = Some(request); self.options = options; - self.list = ListSelectionView::new(params, self.app_event_tx.clone()); + self.list = + ListSelectionView::new(params, self.app_event_tx.clone(), self.list_keymap.clone()); } fn build_options( request: &ApprovalRequest, header: Box, _features: &Features, + approval_keymap: &ApprovalKeymap, + list_keymap: &ListKeymap, ) -> (Vec, SelectionViewParams) { let (options, title) = match request { ApprovalRequest::Exec { @@ -206,6 +243,7 @@ impl ApprovalOverlay { available_decisions, network_approval_context.as_ref(), additional_permissions.as_ref(), + approval_keymap, ), network_approval_context.as_ref().map_or_else( || "Would you like to run the following command?".to_string(), @@ -218,15 +256,15 @@ impl ApprovalOverlay { ), ), ApprovalRequest::Permissions { .. } => ( - permissions_options(), + permissions_options(approval_keymap), "Would you like to grant these permissions?".to_string(), ), ApprovalRequest::ApplyPatch { .. } => ( - patch_options(), + patch_options(approval_keymap), "Would you like to make the following edits?".to_string(), ), ApprovalRequest::McpElicitation { server_name, .. } => ( - elicitation_options(), + elicitation_options(approval_keymap), format!("{server_name} needs your approval."), ), }; @@ -241,16 +279,14 @@ impl ApprovalOverlay { .iter() .map(|opt| SelectionItem { name: opt.label.clone(), - display_shortcut: opt - .display_shortcut - .or_else(|| opt.additional_shortcuts.first().copied()), + display_shortcut: opt.shortcuts.first().copied(), dismiss_on_select: false, ..Default::default() }) .collect(); let params = SelectionViewParams { - footer_hint: Some(approval_footer_hint(request)), + footer_hint: Some(approval_footer_hint(request, approval_keymap, list_keymap)), items, header, ..Default::default() @@ -410,69 +446,9 @@ impl ApprovalOverlay { } } - fn try_handle_shortcut(&mut self, key_event: &KeyEvent) -> bool { - match key_event { - KeyEvent { - kind: KeyEventKind::Press, - code: KeyCode::Char('a'), - modifiers, - .. - } if modifiers.contains(KeyModifiers::CONTROL) => { - if let Some(request) = self.current_request.as_ref() { - self.app_event_tx - .send(AppEvent::FullScreenApprovalRequest(request.clone())); - true - } else { - false - } - } - KeyEvent { - kind: KeyEventKind::Press, - code: KeyCode::Char('o'), - .. - } => { - if let Some(request) = self.current_request.as_ref() { - if request.thread_label().is_some() { - self.app_event_tx - .send(AppEvent::SelectAgentThread(request.thread_id())); - true - } else { - false - } - } else { - false - } - } - e => { - if let Some(idx) = self - .options - .iter() - .position(|opt| opt.shortcuts().any(|s| s.is_press(*e))) - { - self.apply_selection(idx); - true - } else { - false - } - } - } - } -} - -impl BottomPaneView for ApprovalOverlay { - fn handle_key_event(&mut self, key_event: KeyEvent) { - if self.try_handle_shortcut(&key_event) { - return; - } - self.list.handle_key_event(key_event); - if let Some(idx) = self.list.take_last_selected_index() { - self.apply_selection(idx); - } - } - - fn on_ctrl_c(&mut self) -> CancellationEvent { + fn cancel_current_request(&mut self) { if self.done { - return CancellationEvent::Handled; + return; } if !self.current_complete && let Some(request) = self.current_request.as_ref() @@ -510,6 +486,63 @@ impl BottomPaneView for ApprovalOverlay { } self.queue.clear(); self.done = true; + } + + /// Apply approval-specific shortcuts before delegating to list navigation. + /// + /// `open_fullscreen` is handled here because it is orthogonal to list item + /// selection and should work regardless of current highlighted row. + fn try_handle_shortcut(&mut self, key_event: &KeyEvent) -> bool { + if key_event.kind == KeyEventKind::Press + && self.approval_keymap.open_fullscreen.is_pressed(*key_event) + && let Some(request) = self.current_request.as_ref() + { + self.app_event_tx + .send(AppEvent::FullScreenApprovalRequest(request.clone())); + return true; + } + + if key_event.kind == KeyEventKind::Press + && self.approval_keymap.open_thread.is_pressed(*key_event) + && let Some(request) = self.current_request.as_ref() + && request.thread_label().is_some() + { + self.app_event_tx + .send(AppEvent::SelectAgentThread(request.thread_id())); + return true; + } + + if self.list_keymap.cancel.is_pressed(*key_event) { + self.cancel_current_request(); + return true; + } + + if let Some(idx) = self + .options + .iter() + .position(|opt| opt.shortcuts.iter().any(|s| s.is_press(*key_event))) + { + self.apply_selection(idx); + true + } else { + false + } + } +} + +impl BottomPaneView for ApprovalOverlay { + fn handle_key_event(&mut self, key_event: KeyEvent) { + if self.try_handle_shortcut(&key_event) { + return; + } + self.list.handle_key_event(key_event); + if let Some(idx) = self.list.take_last_selected_index() { + self.apply_selection(idx); + } + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + self.cancel_current_request(); CancellationEvent::Handled } @@ -548,20 +581,27 @@ impl Renderable for ApprovalOverlay { } } -fn approval_footer_hint(request: &ApprovalRequest) -> Line<'static> { - let mut spans = vec![ - "Press ".into(), - key_hint::plain(KeyCode::Enter).into(), - " to confirm or ".into(), - key_hint::plain(KeyCode::Esc).into(), - " to cancel".into(), - ]; - if request.thread_label().is_some() { - spans.extend([ - " or ".into(), - key_hint::plain(KeyCode::Char('o')).into(), - " to open thread".into(), - ]); +fn approval_footer_hint( + request: &ApprovalRequest, + approval_keymap: &ApprovalKeymap, + list_keymap: &ListKeymap, +) -> Line<'static> { + let mut spans = accept_cancel_hint_line( + primary_binding(&list_keymap.accept), + "to confirm", + primary_binding(&list_keymap.cancel), + "to cancel", + ) + .spans; + if request.thread_label().is_some() + && let Some(open_thread) = primary_binding(&approval_keymap.open_thread) + { + if !spans.is_empty() { + spans.push(" or ".into()); + } else { + spans.push("Press ".into()); + } + spans.extend([open_thread.into(), " to open thread".into()]); } Line::from(spans) } @@ -705,22 +745,14 @@ enum PermissionsDecision { struct ApprovalOption { label: String, decision: ApprovalDecision, - display_shortcut: Option, - additional_shortcuts: Vec, -} - -impl ApprovalOption { - fn shortcuts(&self) -> impl Iterator + '_ { - self.display_shortcut - .into_iter() - .chain(self.additional_shortcuts.iter().copied()) - } + shortcuts: Vec, } fn exec_options( available_decisions: &[ReviewDecision], network_approval_context: Option<&NetworkApprovalContext>, additional_permissions: Option<&AdditionalPermissionProfile>, + keymap: &ApprovalKeymap, ) -> Vec { available_decisions .iter() @@ -732,8 +764,7 @@ fn exec_options( "Yes, proceed".to_string() }, decision: ApprovalDecision::Review(ReviewDecision::Approved), - display_shortcut: None, - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))], + shortcuts: keymap.approve.clone(), }), ReviewDecision::ApprovedExecpolicyAmendment { proposed_execpolicy_amendment, @@ -753,8 +784,7 @@ fn exec_options( proposed_execpolicy_amendment: proposed_execpolicy_amendment.clone(), }, ), - display_shortcut: None, - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('p'))], + shortcuts: keymap.approve_for_prefix.clone(), }) } ReviewDecision::ApprovedForSession => Some(ApprovalOption { @@ -766,20 +796,19 @@ fn exec_options( "Yes, and don't ask again for this command in this session".to_string() }, decision: ApprovalDecision::Review(ReviewDecision::ApprovedForSession), - display_shortcut: None, - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('a'))], + shortcuts: keymap.approve_for_session.clone(), }), ReviewDecision::NetworkPolicyAmendment { network_policy_amendment, } => { - let (label, shortcut) = match network_policy_amendment.action { + let (label, shortcuts) = match network_policy_amendment.action { NetworkPolicyRuleAction::Allow => ( "Yes, and allow this host in the future".to_string(), - KeyCode::Char('p'), + keymap.approve_for_prefix.clone(), ), NetworkPolicyRuleAction::Deny => ( "No, and block this host in the future".to_string(), - KeyCode::Char('d'), + keymap.deny.clone(), ), }; Some(ApprovalOption { @@ -787,22 +816,19 @@ fn exec_options( decision: ApprovalDecision::Review(ReviewDecision::NetworkPolicyAmendment { network_policy_amendment: network_policy_amendment.clone(), }), - display_shortcut: None, - additional_shortcuts: vec![key_hint::plain(shortcut)], + shortcuts, }) } ReviewDecision::Denied => Some(ApprovalOption { label: "No, continue without running it".to_string(), decision: ApprovalDecision::Review(ReviewDecision::Denied), - display_shortcut: None, - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('d'))], + shortcuts: keymap.deny.clone(), }), ReviewDecision::TimedOut => None, ReviewDecision::Abort => Some(ApprovalOption { label: "No, and tell Codex what to do differently".to_string(), decision: ApprovalDecision::Review(ReviewDecision::Abort), - display_shortcut: Some(key_hint::plain(KeyCode::Esc)), - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))], + shortcuts: keymap.decline.clone(), }), }) .collect() @@ -893,79 +919,97 @@ fn path_label(base: &str, subpath: &Option) -> String { } } -fn patch_options() -> Vec { +fn patch_options(keymap: &ApprovalKeymap) -> Vec { vec![ ApprovalOption { label: "Yes, proceed".to_string(), decision: ApprovalDecision::Review(ReviewDecision::Approved), - display_shortcut: None, - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))], + shortcuts: keymap.approve.clone(), }, ApprovalOption { label: "Yes, and don't ask again for these files".to_string(), decision: ApprovalDecision::Review(ReviewDecision::ApprovedForSession), - display_shortcut: None, - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('a'))], + shortcuts: keymap.approve_for_session.clone(), }, ApprovalOption { label: "No, and tell Codex what to do differently".to_string(), decision: ApprovalDecision::Review(ReviewDecision::Abort), - display_shortcut: Some(key_hint::plain(KeyCode::Esc)), - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))], + shortcuts: keymap.decline.clone(), }, ] } -fn permissions_options() -> Vec { +fn permissions_options(keymap: &ApprovalKeymap) -> Vec { + let deny_shortcuts = keymap + .deny + .iter() + .copied() + .filter(|shortcut| shortcut.parts() != (KeyCode::Esc, KeyModifiers::NONE)) + .collect(); + vec![ ApprovalOption { label: "Yes, grant these permissions for this turn".to_string(), decision: ApprovalDecision::Permissions(PermissionsDecision::GrantForTurn), - display_shortcut: None, - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))], + shortcuts: keymap.approve.clone(), }, ApprovalOption { label: "Yes, grant for this turn with strict auto review".to_string(), decision: ApprovalDecision::Permissions( PermissionsDecision::GrantForTurnWithStrictAutoReview, ), - display_shortcut: None, - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('r'))], + shortcuts: vec![key_hint::plain(KeyCode::Char('r'))], }, ApprovalOption { label: "Yes, grant these permissions for this session".to_string(), decision: ApprovalDecision::Permissions(PermissionsDecision::GrantForSession), - display_shortcut: None, - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('a'))], + shortcuts: keymap.approve_for_session.clone(), }, ApprovalOption { label: "No, continue without permissions".to_string(), decision: ApprovalDecision::Permissions(PermissionsDecision::Deny), - display_shortcut: None, - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))], + shortcuts: deny_shortcuts, }, ] } -fn elicitation_options() -> Vec { +/// Build MCP elicitation options with stable cancellation semantics. +/// +/// `Esc` is always treated as cancel for elicitation prompts, even if users +/// customize `decline`/`cancel` bindings. We keep this as a hard contract so +/// dismissal remains a safe abort path and never silently maps to "continue +/// without requested info." Any decline/cancel overlap is removed from the +/// decline option in elicitation mode to preserve this invariant. +fn elicitation_options(keymap: &ApprovalKeymap) -> Vec { + let mut cancel_shortcuts = vec![key_hint::plain(KeyCode::Esc)]; + for shortcut in &keymap.cancel { + if !cancel_shortcuts.contains(shortcut) { + cancel_shortcuts.push(*shortcut); + } + } + + let decline_shortcuts: Vec = keymap + .decline + .iter() + .copied() + .filter(|shortcut| !cancel_shortcuts.contains(shortcut)) + .collect(); + vec![ ApprovalOption { label: "Yes, provide the requested info".to_string(), decision: ApprovalDecision::McpElicitation(ElicitationAction::Accept), - display_shortcut: None, - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))], + shortcuts: keymap.approve.clone(), }, ApprovalOption { label: "No, but continue without it".to_string(), decision: ApprovalDecision::McpElicitation(ElicitationAction::Decline), - display_shortcut: None, - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))], + shortcuts: decline_shortcuts, }, ApprovalOption { label: "Cancel this request".to_string(), decision: ApprovalDecision::McpElicitation(ElicitationAction::Cancel), - display_shortcut: Some(key_hint::plain(KeyCode::Esc)), - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('c'))], + shortcuts: cancel_shortcuts, }, ] } @@ -983,6 +1027,7 @@ mod tests { use codex_protocol::protocol::NetworkApprovalProtocol; use codex_protocol::protocol::NetworkPolicyAmendment; use codex_utils_absolute_path::AbsolutePathBuf; + use crossterm::event::KeyModifiers; use insta::assert_snapshot; use pretty_assertions::assert_eq; use tokio::sync::mpsc::unbounded_channel; @@ -1018,6 +1063,37 @@ mod tests { }) } + fn make_overlay( + request: ApprovalRequest, + app_event_tx: AppEventSender, + features: Features, + ) -> ApprovalOverlay { + let keymap = crate::keymap::RuntimeKeymap::defaults(); + make_overlay_with_keymap( + request, + app_event_tx, + features, + keymap.approval, + keymap.list, + ) + } + + fn make_overlay_with_keymap( + request: ApprovalRequest, + app_event_tx: AppEventSender, + features: Features, + approval_keymap: ApprovalKeymap, + list_keymap: ListKeymap, + ) -> ApprovalOverlay { + ApprovalOverlay::new( + request, + app_event_tx, + features, + approval_keymap, + list_keymap, + ) + } + fn make_exec_request() -> ApprovalRequest { ApprovalRequest::Exec { thread_id: ThreadId::new(), @@ -1049,22 +1125,94 @@ mod tests { } } + fn make_elicitation_request() -> ApprovalRequest { + ApprovalRequest::McpElicitation { + thread_id: ThreadId::new(), + thread_label: None, + server_name: "test-server".to_string(), + request_id: RequestId::String("request-1".to_string()), + message: "Need more information".to_string(), + } + } + #[test] fn ctrl_c_aborts_and_clears_queue() { let (tx, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx); - let mut view = ApprovalOverlay::new(make_exec_request(), tx, Features::with_defaults()); + let mut view = make_overlay(make_exec_request(), tx, Features::with_defaults()); view.enqueue_request(make_exec_request()); assert_eq!(CancellationEvent::Handled, view.on_ctrl_c()); assert!(view.queue.is_empty()); assert!(view.is_complete()); } + #[test] + fn configured_list_cancel_aborts_exec_approval() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut keymap = crate::keymap::RuntimeKeymap::defaults(); + keymap.list.cancel = vec![key_hint::plain(KeyCode::Char('q'))]; + let mut view = make_overlay_with_keymap( + make_exec_request(), + tx, + Features::with_defaults(), + keymap.approval, + keymap.list, + ); + + view.handle_key_event(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)); + + assert!(view.is_complete()); + let mut decision = None; + while let Ok(ev) = rx.try_recv() { + if let AppEvent::SubmitThreadOp { + op: Op::ExecApproval { decision: d, .. }, + .. + } = ev + { + decision = Some(d); + break; + } + } + assert_eq!(decision, Some(ReviewDecision::Abort)); + } + + #[test] + fn configured_list_cancel_cancels_mcp_elicitation() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut keymap = crate::keymap::RuntimeKeymap::defaults(); + keymap.list.cancel = vec![key_hint::plain(KeyCode::Char('q'))]; + let mut view = make_overlay_with_keymap( + make_elicitation_request(), + tx, + Features::with_defaults(), + keymap.approval, + keymap.list, + ); + + view.handle_key_event(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)); + + assert!(view.is_complete()); + let mut decision = None; + while let Ok(ev) = rx.try_recv() { + if let AppEvent::SubmitThreadOp { + op: Op::ResolveElicitation { decision: d, .. }, + .. + } = ev + { + decision = Some(d); + break; + } + } + assert_eq!(decision, Some(ElicitationAction::Cancel)); + } + #[test] fn shortcut_triggers_selection() { let (tx, mut rx) = unbounded_channel::(); let tx = AppEventSender::new(tx); - let mut view = ApprovalOverlay::new(make_exec_request(), tx, Features::with_defaults()); + let mut view = make_overlay(make_exec_request(), tx, Features::with_defaults()); assert!(!view.is_complete()); view.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); // We expect at least one thread-scoped approval op message in the queue. @@ -1078,11 +1226,103 @@ mod tests { assert!(saw_op, "expected approval decision to emit an op"); } + #[test] + fn deny_shortcut_submits_denied_exec_decision() { + let (tx, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let mut view = make_overlay( + ApprovalRequest::Exec { + thread_id: ThreadId::new(), + thread_label: None, + id: "test".to_string(), + command: vec!["echo".to_string(), "hi".to_string()], + reason: None, + available_decisions: vec![ReviewDecision::Approved, ReviewDecision::Denied], + network_approval_context: None, + additional_permissions: None, + }, + tx, + Features::with_defaults(), + ); + + view.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + + let mut saw_denied = false; + while let Ok(ev) = rx.try_recv() { + if let AppEvent::SubmitThreadOp { + op: Op::ExecApproval { decision, .. }, + .. + } = ev + { + assert_eq!(decision, ReviewDecision::Denied); + saw_denied = true; + break; + } + } + assert!(saw_denied, "expected deny shortcut to emit denied decision"); + } + + #[test] + fn network_deny_shortcut_submits_policy_deny_decision() { + let (tx, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let amendment = NetworkPolicyAmendment { + host: "example.com".to_string(), + action: NetworkPolicyRuleAction::Deny, + }; + let mut view = make_overlay( + ApprovalRequest::Exec { + thread_id: ThreadId::new(), + thread_label: None, + id: "test".to_string(), + command: vec!["curl".to_string(), "https://example.com".to_string()], + reason: None, + available_decisions: vec![ + ReviewDecision::Approved, + ReviewDecision::NetworkPolicyAmendment { + network_policy_amendment: amendment.clone(), + }, + ], + network_approval_context: Some(NetworkApprovalContext { + host: "example.com".to_string(), + protocol: NetworkApprovalProtocol::Https, + }), + additional_permissions: None, + }, + tx, + Features::with_defaults(), + ); + + view.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + + let mut saw_deny = false; + while let Ok(ev) = rx.try_recv() { + if let AppEvent::SubmitThreadOp { + op: Op::ExecApproval { decision, .. }, + .. + } = ev + { + assert_eq!( + decision, + ReviewDecision::NetworkPolicyAmendment { + network_policy_amendment: amendment + } + ); + saw_deny = true; + break; + } + } + assert!( + saw_deny, + "expected deny shortcut to emit network policy deny decision" + ); + } + #[test] fn resolved_request_dismisses_overlay_without_emitting_abort() { let (tx, mut rx) = unbounded_channel::(); let tx = AppEventSender::new(tx); - let mut view = ApprovalOverlay::new(make_exec_request(), tx, Features::with_defaults()); + let mut view = make_overlay(make_exec_request(), tx, Features::with_defaults()); assert!( view.dismiss_app_server_request(&ResolvedAppServerRequest::ExecApproval { @@ -1104,7 +1344,7 @@ mod tests { let (tx, mut rx) = unbounded_channel::(); let tx = AppEventSender::new(tx); let thread_id = ThreadId::new(); - let mut view = ApprovalOverlay::new( + let mut view = make_overlay( ApprovalRequest::Exec { thread_id, thread_label: Some("Robie [explorer]".to_string()), @@ -1128,11 +1368,49 @@ mod tests { ); } + #[test] + fn configured_open_thread_shortcut_opens_source_thread() { + let (tx, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let thread_id = ThreadId::new(); + let mut keymap = crate::keymap::RuntimeKeymap::defaults(); + keymap.approval.open_thread = vec![key_hint::plain(KeyCode::Char('x'))]; + let mut view = make_overlay_with_keymap( + ApprovalRequest::Exec { + thread_id, + thread_label: Some("Robie [explorer]".to_string()), + id: "test".to_string(), + command: vec!["echo".to_string(), "hi".to_string()], + reason: None, + available_decisions: vec![ReviewDecision::Approved, ReviewDecision::Abort], + network_approval_context: None, + additional_permissions: None, + }, + tx, + Features::with_defaults(), + keymap.approval, + keymap.list, + ); + + view.handle_key_event(KeyEvent::new( + KeyCode::Char('o'), + /*modifiers*/ KeyModifiers::NONE, + )); + assert!(rx.try_recv().is_err()); + + view.handle_key_event(KeyEvent::new( + KeyCode::Char('x'), + /*modifiers*/ KeyModifiers::NONE, + )); + let event = rx.try_recv().expect("expected select-agent-thread event"); + assert!(matches!(event, AppEvent::SelectAgentThread(id) if id == thread_id)); + } + #[test] fn cross_thread_footer_hint_mentions_o_shortcut() { let (tx, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx); - let view = ApprovalOverlay::new( + let view = make_overlay( ApprovalRequest::Exec { thread_id: ThreadId::new(), thread_label: Some("Robie [explorer]".to_string()), @@ -1157,7 +1435,7 @@ mod tests { fn exec_prefix_option_emits_execpolicy_amendment() { let (tx, mut rx) = unbounded_channel::(); let tx = AppEventSender::new(tx); - let mut view = ApprovalOverlay::new( + let mut view = make_overlay( ApprovalRequest::Exec { thread_id: ThreadId::new(), thread_label: None, @@ -1209,7 +1487,7 @@ mod tests { fn network_deny_forever_shortcut_is_not_bound() { let (tx, mut rx) = unbounded_channel::(); let tx = AppEventSender::new(tx); - let mut view = ApprovalOverlay::new( + let mut view = make_overlay( ApprovalRequest::Exec { thread_id: ThreadId::new(), thread_label: None, @@ -1260,7 +1538,7 @@ mod tests { additional_permissions: None, }; - let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults()); + let view = make_overlay(exec_request, tx, Features::with_defaults()); let mut buf = Buffer::empty(Rect::new(0, 0, 80, view.desired_height(/*width*/ 80))); view.render( Rect::new(0, 0, 80, view.desired_height(/*width*/ 80)), @@ -1288,6 +1566,7 @@ mod tests { host: "example.com".to_string(), protocol: NetworkApprovalProtocol::Https, }; + let keymap = crate::keymap::RuntimeKeymap::defaults(); let options = exec_options( &[ ReviewDecision::Approved, @@ -1302,6 +1581,7 @@ mod tests { ], Some(&network_context), /*additional_permissions*/ None, + &keymap.approval, ); let labels: Vec = options.into_iter().map(|option| option.label).collect(); @@ -1318,6 +1598,7 @@ mod tests { #[test] fn generic_exec_options_can_offer_allow_for_session() { + let keymap = crate::keymap::RuntimeKeymap::defaults(); let options = exec_options( &[ ReviewDecision::Approved, @@ -1326,6 +1607,7 @@ mod tests { ], /*network_approval_context*/ None, /*additional_permissions*/ None, + &keymap.approval, ); let labels: Vec = options.into_iter().map(|option| option.label).collect(); @@ -1341,6 +1623,7 @@ mod tests { #[test] fn additional_permissions_exec_options_hide_execpolicy_amendment() { + let keymap = crate::keymap::RuntimeKeymap::defaults(); let additional_permissions = AdditionalPermissionProfile { file_system: Some(FileSystemPermissions::from_read_write_roots( Some(vec![absolute_path("/tmp/readme.txt")]), @@ -1352,6 +1635,7 @@ mod tests { &[ReviewDecision::Approved, ReviewDecision::Abort], /*network_approval_context*/ None, Some(&additional_permissions), + &keymap.approval, ); let labels: Vec = options.into_iter().map(|option| option.label).collect(); @@ -1366,7 +1650,8 @@ mod tests { #[test] fn permissions_options_use_expected_labels() { - let labels: Vec = permissions_options() + let keymap = crate::keymap::RuntimeKeymap::defaults(); + let labels: Vec = permissions_options(&keymap.approval) .into_iter() .map(|option| option.label) .collect(); @@ -1414,8 +1699,7 @@ mod tests { fn permissions_session_shortcut_submits_session_scope() { let (tx, mut rx) = unbounded_channel::(); let tx = AppEventSender::new(tx); - let mut view = - ApprovalOverlay::new(make_permissions_request(), tx, Features::with_defaults()); + let mut view = make_overlay(make_permissions_request(), tx, Features::with_defaults()); view.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); @@ -1437,12 +1721,48 @@ mod tests { ); } + #[test] + fn permissions_deny_shortcut_uses_deny_keymap() { + let (tx, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let mut keymap = crate::keymap::RuntimeKeymap::defaults(); + keymap.approval.deny = vec![key_hint::plain(KeyCode::Char('x'))]; + keymap.approval.decline = Vec::new(); + let mut view = make_overlay_with_keymap( + make_permissions_request(), + tx, + Features::with_defaults(), + keymap.approval, + keymap.list, + ); + + view.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + + let mut saw_op = false; + while let Ok(ev) = rx.try_recv() { + if let AppEvent::SubmitThreadOp { + op: Op::RequestPermissionsResponse { response, .. }, + .. + } = ev + { + assert!(response.permissions.is_empty()); + assert_eq!(response.scope, PermissionGrantScope::Turn); + assert!(!response.strict_auto_review); + saw_op = true; + break; + } + } + assert!( + saw_op, + "expected permission deny shortcut to emit an empty permission response" + ); + } + #[test] fn permissions_strict_auto_review_shortcut_submits_turn_scope_with_strict_review() { let (tx, mut rx) = unbounded_channel::(); let tx = AppEventSender::new(tx); - let mut view = - ApprovalOverlay::new(make_permissions_request(), tx, Features::with_defaults()); + let mut view = make_overlay(make_permissions_request(), tx, Features::with_defaults()); view.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)); @@ -1488,10 +1808,10 @@ mod tests { }), }; - let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults()); - let mut buf = Buffer::empty(Rect::new(0, 0, 120, view.desired_height(/*width*/ 120))); + let view = make_overlay(exec_request, tx, Features::with_defaults()); + let mut buf = Buffer::empty(Rect::new(0, 0, 100, view.desired_height(/*width*/ 100))); view.render( - Rect::new(0, 0, 120, view.desired_height(/*width*/ 120)), + Rect::new(0, 0, 100, view.desired_height(/*width*/ 100)), &mut buf, ); @@ -1538,7 +1858,7 @@ mod tests { }), }; - let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults()); + let view = make_overlay(exec_request, tx, Features::with_defaults()); assert_snapshot!( "approval_overlay_additional_permissions_prompt", normalize_snapshot_paths(render_overlay_lines(&view, /*width*/ 120)) @@ -1549,7 +1869,7 @@ mod tests { fn permissions_prompt_snapshot() { let (tx, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx); - let view = ApprovalOverlay::new(make_permissions_request(), tx, Features::with_defaults()); + let view = make_overlay(make_permissions_request(), tx, Features::with_defaults()); assert_snapshot!( "approval_overlay_permissions_prompt", normalize_snapshot_paths(render_overlay_lines(&view, /*width*/ 120)) @@ -1575,7 +1895,14 @@ mod tests { cwd: absolute_path("/tmp"), changes, }; - let view = ApprovalOverlay::new(request, tx, Features::with_defaults()); + let keymap = crate::keymap::RuntimeKeymap::defaults(); + let view = ApprovalOverlay::new( + request, + tx, + Features::with_defaults(), + keymap.approval, + keymap.list, + ); let rendered = render_overlay_lines(&view, /*width*/ 120); assert!(rendered.contains("Thread: Banach [worker]")); assert!(rendered.contains("o to open thread")); @@ -1610,7 +1937,7 @@ mod tests { additional_permissions: None, }; - let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults()); + let view = make_overlay(exec_request, tx, Features::with_defaults()); let mut buf = Buffer::empty(Rect::new(0, 0, 100, view.desired_height(/*width*/ 100))); view.render( Rect::new(0, 0, 100, view.desired_height(/*width*/ 100)), @@ -1642,6 +1969,27 @@ mod tests { ); } + #[test] + fn ctrl_shift_a_opens_fullscreen() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut view = make_overlay(make_exec_request(), tx, Features::with_defaults()); + + view.handle_key_event(KeyEvent::new( + KeyCode::Char('a'), + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + )); + + let mut saw_fullscreen = false; + while let Ok(ev) = rx.try_recv() { + if matches!(ev, AppEvent::FullScreenApprovalRequest(_)) { + saw_fullscreen = true; + break; + } + } + assert!(saw_fullscreen, "expected ctrl+shift+a to open fullscreen"); + } + #[test] fn exec_history_cell_wraps_with_two_space_indent() { let command = vec![ @@ -1673,11 +2021,97 @@ mod tests { assert_eq!(rendered, expected); } + #[test] + fn esc_cancels_mcp_elicitation() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut view = make_overlay(make_elicitation_request(), tx, Features::with_defaults()); + + view.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + let mut decision = None; + while let Ok(ev) = rx.try_recv() { + if let AppEvent::SubmitThreadOp { + op: Op::ResolveElicitation { decision: d, .. }, + .. + } = ev + { + decision = Some(d); + break; + } + } + assert_eq!(decision, Some(ElicitationAction::Cancel)); + } + + #[test] + fn esc_still_cancels_elicitation_with_custom_overlap() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut keymap = crate::keymap::RuntimeKeymap::defaults(); + keymap.approval.decline = vec![ + key_hint::plain(KeyCode::Esc), + key_hint::plain(KeyCode::Char('n')), + ]; + keymap.approval.cancel = vec![key_hint::plain(KeyCode::Char('x'))]; + + let mut view = make_overlay_with_keymap( + make_elicitation_request(), + tx, + Features::with_defaults(), + keymap.approval, + keymap.list, + ); + + view.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + let mut esc_decision = None; + while let Ok(ev) = rx.try_recv() { + if let AppEvent::SubmitThreadOp { + op: Op::ResolveElicitation { decision, .. }, + .. + } = ev + { + esc_decision = Some(decision); + break; + } + } + assert_eq!(esc_decision, Some(ElicitationAction::Cancel)); + + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut keymap = crate::keymap::RuntimeKeymap::defaults(); + keymap.approval.decline = vec![ + key_hint::plain(KeyCode::Esc), + key_hint::plain(KeyCode::Char('n')), + ]; + keymap.approval.cancel = vec![key_hint::plain(KeyCode::Char('x'))]; + + let mut view = make_overlay_with_keymap( + make_elicitation_request(), + tx, + Features::with_defaults(), + keymap.approval, + keymap.list, + ); + view.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); + let mut n_decision = None; + while let Ok(ev) = rx.try_recv() { + if let AppEvent::SubmitThreadOp { + op: Op::ResolveElicitation { decision, .. }, + .. + } = ev + { + n_decision = Some(decision); + break; + } + } + assert_eq!(n_decision, Some(ElicitationAction::Decline)); + } + #[test] fn enter_sets_last_selected_index_without_dismissing() { let (tx_raw, mut rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); - let mut view = ApprovalOverlay::new(make_exec_request(), tx, Features::with_defaults()); + let mut view = make_overlay(make_exec_request(), tx, Features::with_defaults()); view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert!( diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 1a0e39d9c3..8ee722113f 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -155,6 +155,7 @@ use super::command_popup::CommandPopup; use super::command_popup::CommandPopupFlags; use super::file_search_popup::FileSearchPopup; use super::footer::CollaborationModeIndicator; +use super::footer::FooterKeyHints; use super::footer::FooterMode; use super::footer::FooterProps; use super::footer::GoalStatusIndicator; @@ -185,6 +186,10 @@ use super::slash_commands; use super::slash_commands::BuiltinCommandFlags; use crate::bottom_pane::paste_burst::FlushResult; use crate::bottom_pane::prompt_args::parse_slash_name; +use crate::key_hint::KeyBindingListExt; +use crate::keymap::EditorKeymap; +use crate::keymap::RuntimeKeymap; +use crate::keymap::primary_binding; use crate::render::Insets; use crate::render::RectExt; use crate::render::renderable::Renderable; @@ -390,6 +395,20 @@ pub(crate) struct ChatComposer { // Agent label injected into the footer's contextual row when multi-agent mode is active. active_agent_label: Option, history_search: Option, + submit_keys: Vec, + queue_keys: Vec, + toggle_shortcuts_keys: Vec, + history_search_previous_keys: Vec, + history_search_next_keys: Vec, + editor_keymap: EditorKeymap, + footer_external_editor_key: Option, + footer_show_transcript_key: Option, + footer_insert_newline_key: Option, + footer_queue_key: Option, + footer_toggle_shortcuts_key: Option, + footer_history_search_key: Option, + footer_reasoning_down_key: Option, + footer_reasoning_up_key: Option, } #[derive(Clone, Debug)] @@ -486,6 +505,8 @@ impl ChatComposer { config: ChatComposerConfig, ) -> Self { let use_shift_enter_hint = enhanced_keys_supported; + let default_keymap = RuntimeKeymap::defaults(); + let default_editor_keymap = default_keymap.editor.clone(); let mut this = Self { textarea: TextArea::new(), @@ -549,6 +570,32 @@ impl ChatComposer { side_conversation_context_label: None, active_agent_label: None, history_search: None, + submit_keys: vec![key_hint::plain(KeyCode::Enter)], + queue_keys: vec![key_hint::plain(KeyCode::Tab)], + toggle_shortcuts_keys: vec![ + key_hint::plain(KeyCode::Char('?')), + key_hint::shift(KeyCode::Char('?')), + ], + history_search_previous_keys: default_keymap.composer.history_search_previous.clone(), + history_search_next_keys: default_keymap.composer.history_search_next.clone(), + editor_keymap: default_editor_keymap, + footer_external_editor_key: Some(key_hint::ctrl(KeyCode::Char('g'))), + footer_show_transcript_key: Some(key_hint::ctrl(KeyCode::Char('t'))), + footer_insert_newline_key: footer_insert_newline_key( + &default_keymap.editor.insert_newline, + use_shift_enter_hint, + ), + footer_queue_key: Some(key_hint::plain(KeyCode::Tab)), + footer_toggle_shortcuts_key: Some(key_hint::plain(KeyCode::Char('?'))), + footer_history_search_key: primary_binding( + &default_keymap.composer.history_search_previous, + ), + footer_reasoning_down_key: primary_binding( + &default_keymap.chat.decrease_reasoning_effort, + ), + footer_reasoning_up_key: primary_binding( + &default_keymap.chat.increase_reasoning_effort, + ), }; // Apply configuration via the setter to keep side-effects centralized. this.set_disable_paste_burst(disable_paste_burst); @@ -626,6 +673,31 @@ impl ChatComposer { self.goal_command_enabled = enabled; } + /// Replace composer, editor, and footer-hint key bindings from one runtime snapshot. + /// + /// Submit and queue bindings are cached here because composer dispatch must + /// check them before generic textarea editing. The embedded textarea receives + /// the same snapshot's editor bindings so a live remap cannot leave submit + /// keys updated while cursor/editing keys still use old defaults. + pub(crate) fn set_keymap_bindings(&mut self, keymap: &RuntimeKeymap) { + self.submit_keys = keymap.composer.submit.clone(); + self.queue_keys = keymap.composer.queue.clone(); + self.toggle_shortcuts_keys = keymap.composer.toggle_shortcuts.clone(); + self.history_search_previous_keys = keymap.composer.history_search_previous.clone(); + self.history_search_next_keys = keymap.composer.history_search_next.clone(); + self.editor_keymap = keymap.editor.clone(); + self.textarea.set_keymap_bindings(&self.editor_keymap); + self.footer_external_editor_key = primary_binding(&keymap.app.open_external_editor); + self.footer_show_transcript_key = primary_binding(&keymap.app.open_transcript); + self.footer_insert_newline_key = + footer_insert_newline_key(&keymap.editor.insert_newline, self.use_shift_enter_hint); + self.footer_queue_key = primary_binding(&keymap.composer.queue); + self.footer_toggle_shortcuts_key = primary_binding(&keymap.composer.toggle_shortcuts); + self.footer_history_search_key = primary_binding(&keymap.composer.history_search_previous); + self.footer_reasoning_down_key = primary_binding(&keymap.chat.decrease_reasoning_effort); + self.footer_reasoning_up_key = primary_binding(&keymap.chat.increase_reasoning_effort); + } + pub fn set_collaboration_mode_indicator( &mut self, indicator: Option, @@ -1445,7 +1517,7 @@ impl ChatComposer { return self.handle_history_search_key(key_event); } - if Self::is_history_search_key(&key_event) { + if Self::is_history_search_key(&key_event, &self.history_search_previous_keys) { return self.begin_history_search(); } @@ -1630,7 +1702,7 @@ impl ChatComposer { if self.disable_paste_burst { // When burst detection is disabled, treat IME/non-ASCII input as normal typing. // In particular, do not retro-capture or buffer already-inserted prefix text. - self.textarea.input(input); + self.textarea.input_with_keymap(input, &self.editor_keymap); let text_after = self.textarea.text(); self.pending_pastes .retain(|(placeholder, _)| text_after.contains(placeholder)); @@ -1687,7 +1759,7 @@ impl ChatComposer { if let Some(pasted) = self.paste_burst.flush_before_modified_input() { self.handle_paste(pasted); } - self.textarea.input(input); + self.textarea.input_with_keymap(input, &self.editor_keymap); let text_after = self.textarea.text(); self.pending_pastes @@ -2870,6 +2942,16 @@ impl ChatComposer { } else { self.footer_mode = reset_mode_after_activity(self.footer_mode); } + if self.queue_keys.is_pressed(key_event) + && (self.is_task_running || !self.is_bang_shell_command()) + { + return self.handle_submission(self.is_task_running); + } + + if self.submit_keys.is_pressed(key_event) { + return self.handle_submission(/*should_queue*/ false); + } + match key_event { KeyEvent { code: KeyCode::Char('d'), @@ -2910,19 +2992,6 @@ impl ChatComposer { } self.handle_input_basic(key_event) } - KeyEvent { - code: KeyCode::Tab, - modifiers: KeyModifiers::NONE, - kind: KeyEventKind::Press, - .. - } if self.is_task_running || !self.is_bang_shell_command() => { - self.handle_submission(self.is_task_running) - } - KeyEvent { - code: KeyCode::Enter, - modifiers: KeyModifiers::NONE, - .. - } => self.handle_submission(/*should_queue*/ false), input => self.handle_input_basic(input), } } @@ -3094,7 +3163,7 @@ impl ChatComposer { return (InputResult::None, true); } - self.textarea.input(input); + self.textarea.input_with_keymap(input, &self.editor_keymap); self.sync_bash_mode_from_text(); if let Some(elements_before) = elements_before { @@ -3170,13 +3239,18 @@ impl ChatComposer { } } + /// Handle the dedicated shortcut-overlay toggle key(s). + /// + /// This only toggles when the composer is empty and no paste burst is in + /// progress, so typing/pasting `?` still inserts text instead of opening + /// help. The bound key list intentionally supports terminal-variant + /// modifier reporting (for example `?` vs `shift-?`). fn handle_shortcut_overlay_key(&mut self, key_event: &KeyEvent) -> bool { if key_event.kind != KeyEventKind::Press { return false; } - let toggles = matches!(key_event.code, KeyCode::Char('?')) - && !has_ctrl_or_alt(key_event.modifiers) + let toggles = self.toggle_shortcuts_keys.is_pressed(*key_event) && self.is_empty() && !self.is_in_paste_burst(); @@ -3219,6 +3293,17 @@ impl ChatComposer { context_window_used_tokens: self.context_window_used_tokens, status_line_value: self.status_line_value.clone(), status_line_enabled: self.status_line_enabled, + key_hints: FooterKeyHints { + toggle_shortcuts: self.footer_toggle_shortcuts_key, + queue: self.footer_queue_key, + insert_newline: self.footer_insert_newline_key, + external_editor: self.footer_external_editor_key, + edit_previous: Some(key_hint::plain(KeyCode::Esc)), + show_transcript: self.footer_show_transcript_key, + history_search: self.footer_history_search_key, + reasoning_down: self.footer_reasoning_down_key, + reasoning_up: self.footer_reasoning_up_key, + }, active_agent_label: self.active_agent_label.clone(), } } @@ -3780,6 +3865,23 @@ impl ChatComposer { } } +fn footer_insert_newline_key( + bindings: &[KeyBinding], + enhanced_keys_supported: bool, +) -> Option { + let shift_enter = key_hint::shift(KeyCode::Enter); + if enhanced_keys_supported && bindings.contains(&shift_enter) { + return Some(shift_enter); + } + + let plain_enter = key_hint::plain(KeyCode::Enter); + bindings + .iter() + .copied() + .find(|binding| *binding != plain_enter) + .or_else(|| bindings.first().copied()) +} + #[cfg(not(target_os = "linux"))] impl ChatComposer { pub fn update_recording_meter_in_place(&mut self, id: &str, text: &str) -> bool { @@ -4048,6 +4150,7 @@ impl ChatComposer { show_cycle_hint, show_shortcuts_hint, show_queue_hint, + footer_props.key_hints, )) } FooterMode::EscHint @@ -5248,6 +5351,30 @@ mod tests { assert_eq!(composer.footer_mode(), FooterMode::ComposerHasDraft); } + #[test] + fn shift_question_mark_toggles_shortcut_overlay_when_empty() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_steer_enabled(true); + + let (result, needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::SHIFT)); + assert_eq!(result, InputResult::None); + assert!(needs_redraw, "toggling overlay should request redraw"); + assert_eq!(composer.footer_mode, FooterMode::ShortcutOverlay); + } + /// Behavior: while a paste-like burst is being captured, `?` must not toggle the shortcut /// overlay; it should be treated as part of the pasted content. #[test] @@ -6929,6 +7056,96 @@ mod tests { assert_queued_slash("/does-not-exist"); } + #[test] + fn remapped_submit_does_not_fall_back_to_enter() { + use crate::key_hint; + use crate::keymap::RuntimeKeymap; + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer + .textarea + .set_text_clearing_elements("explain the change"); + composer.textarea.set_cursor(composer.textarea.text().len()); + let mut keymap = RuntimeKeymap::defaults(); + keymap.composer.submit = vec![key_hint::ctrl(KeyCode::Char('j'))]; + composer.set_keymap_bindings(&keymap); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(InputResult::None, result); + assert_eq!("explain the change\n", composer.textarea.text()); + } + + #[test] + fn remapped_queue_does_not_fall_back_to_tab() { + use crate::key_hint; + use crate::keymap::RuntimeKeymap; + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_task_running(/*running*/ true); + composer.textarea.set_text_clearing_elements("queue me"); + let mut keymap = RuntimeKeymap::defaults(); + keymap.composer.queue = vec![key_hint::ctrl(KeyCode::Char('q'))]; + composer.set_keymap_bindings(&keymap); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + + assert_eq!(InputResult::None, result); + assert_eq!("queue me", composer.textarea.text()); + } + + #[test] + fn remapped_history_search_does_not_fall_back_to_ctrl_r() { + use crate::key_hint; + use crate::keymap::RuntimeKeymap; + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + let mut keymap = RuntimeKeymap::defaults(); + keymap.composer.history_search_previous = vec![key_hint::plain(KeyCode::F(2))]; + composer.set_keymap_bindings(&keymap); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + assert!(!composer.history_search_active()); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE)); + assert!(composer.history_search_active()); + } + #[test] fn tab_queues_leading_space_slash_as_plain_text_while_task_running() { use crossterm::event::KeyCode; diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs b/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs index b4f6121563..b75f5e1e80 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs @@ -41,6 +41,8 @@ use super::ComposerDraft; use super::InputResult; use crate::app_event::AppEvent; use crate::key_hint; +use crate::key_hint::KeyBinding; +use crate::key_hint::KeyBindingListExt; use crate::key_hint::has_ctrl_or_alt; use crate::ui_consts::FOOTER_INDENT_COLS; @@ -84,44 +86,12 @@ impl ChatComposer { /// some terminals emit. Callers should only use this before generic text handling; treating the /// raw control character as ordinary input would insert an invisible byte into the search query /// or composer draft. - pub(super) fn is_history_search_key(key_event: &KeyEvent) -> bool { - matches!( - key_event, - KeyEvent { - code: KeyCode::Char(c), - modifiers, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - } if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'r') - ) || matches!( - key_event, - KeyEvent { - code: KeyCode::Char('\u{0012}'), - modifiers: KeyModifiers::NONE, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - } - ) + pub(super) fn is_history_search_key(key_event: &KeyEvent, bindings: &[KeyBinding]) -> bool { + bindings.is_pressed(*key_event) } - fn is_history_search_forward_key(key_event: &KeyEvent) -> bool { - matches!( - key_event, - KeyEvent { - code: KeyCode::Char(c), - modifiers, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - } if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'s') - ) || matches!( - key_event, - KeyEvent { - code: KeyCode::Char('\u{0013}'), - modifiers: KeyModifiers::NONE, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - } - ) + fn is_history_search_forward_key(key_event: &KeyEvent, bindings: &[KeyBinding]) -> bool { + bindings.is_pressed(*key_event) } /// Opens footer-owned reverse history search without previewing history yet. @@ -166,12 +136,14 @@ impl ChatComposer { return (InputResult::None, false); } - if Self::is_history_search_key(&key_event) || matches!(key_event.code, KeyCode::Up) { + if Self::is_history_search_key(&key_event, &self.history_search_previous_keys) + || matches!(key_event.code, KeyCode::Up) + { let result = self.history_search_in_direction(HistorySearchDirection::Older); return (result, true); } - if Self::is_history_search_forward_key(&key_event) + if Self::is_history_search_forward_key(&key_event, &self.history_search_next_keys) || matches!(key_event.code, KeyCode::Down) { let result = self.history_search_in_direction(HistorySearchDirection::Newer); diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 4f4f1d9ae2..3fc18bb140 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -78,6 +78,7 @@ pub(crate) struct FooterProps { pub(crate) context_window_used_tokens: Option, pub(crate) status_line_value: Option>, pub(crate) status_line_enabled: bool, + pub(crate) key_hints: FooterKeyHints, /// Active thread label shown when the footer is rendering contextual information instead of an /// instructional hint. /// @@ -106,6 +107,36 @@ pub(crate) enum GoalStatusIndicator { const MODE_CYCLE_HINT: &str = "shift+tab to cycle"; const FOOTER_CONTEXT_GAP_COLS: u16 = 1; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct FooterKeyHints { + pub(crate) toggle_shortcuts: Option, + pub(crate) queue: Option, + pub(crate) insert_newline: Option, + pub(crate) external_editor: Option, + pub(crate) edit_previous: Option, + pub(crate) show_transcript: Option, + pub(crate) history_search: Option, + pub(crate) reasoning_down: Option, + pub(crate) reasoning_up: Option, +} + +impl FooterKeyHints { + #[cfg(test)] + pub(crate) fn default_bindings() -> Self { + Self { + toggle_shortcuts: Some(key_hint::plain(KeyCode::Char('?'))), + queue: Some(key_hint::plain(KeyCode::Tab)), + insert_newline: Some(key_hint::ctrl(KeyCode::Char('j'))), + external_editor: Some(key_hint::ctrl(KeyCode::Char('g'))), + edit_previous: Some(key_hint::plain(KeyCode::Esc)), + show_transcript: Some(key_hint::ctrl(KeyCode::Char('t'))), + history_search: Some(key_hint::ctrl(KeyCode::Char('r'))), + reasoning_down: Some(key_hint::alt(KeyCode::Char(','))), + reasoning_up: Some(key_hint::alt(KeyCode::Char('.'))), + } + } +} + impl CollaborationModeIndicator { fn label(self, show_cycle_hint: bool) -> String { let suffix = if show_cycle_hint { @@ -284,21 +315,28 @@ struct LeftSideState { fn left_side_line( collaboration_mode_indicator: Option, state: LeftSideState, + key_hints: FooterKeyHints, ) -> Line<'static> { let mut line = Line::from(""); match state.hint { SummaryHintKind::None => {} SummaryHintKind::Shortcuts => { - line.push_span(key_hint::plain(KeyCode::Char('?'))); - line.push_span(" for shortcuts".dim()); + if let Some(key) = key_hints.toggle_shortcuts { + line.push_span(key); + line.push_span(" for shortcuts".dim()); + } } SummaryHintKind::QueueMessage => { - line.push_span(key_hint::plain(KeyCode::Tab)); - line.push_span(" to queue message".dim()); + if let Some(key) = key_hints.queue { + line.push_span(key); + line.push_span(" to queue message".dim()); + } } SummaryHintKind::QueueShort => { - line.push_span(key_hint::plain(KeyCode::Tab)); - line.push_span(" to queue".dim()); + if let Some(key) = key_hints.queue { + line.push_span(key); + line.push_span(" to queue".dim()); + } } }; @@ -327,6 +365,7 @@ pub(crate) fn single_line_footer_layout( show_cycle_hint: bool, show_shortcuts_hint: bool, show_queue_hint: bool, + key_hints: FooterKeyHints, ) -> (SummaryLeft, bool) { let hint_kind = if show_queue_hint { SummaryHintKind::QueueMessage @@ -339,7 +378,7 @@ pub(crate) fn single_line_footer_layout( hint: hint_kind, show_cycle_hint, }; - let default_line = left_side_line(collaboration_mode_indicator, default_state); + let default_line = left_side_line(collaboration_mode_indicator, default_state, key_hints); let default_width = default_line.width() as u16; if default_width > 0 && can_show_left_with_context(area, default_width, context_width) { return (SummaryLeft::Default, true); @@ -349,7 +388,7 @@ pub(crate) fn single_line_footer_layout( if state == default_state { default_line.clone() } else { - left_side_line(collaboration_mode_indicator, state) + left_side_line(collaboration_mode_indicator, state, key_hints) } }; let state_width = |state: LeftSideState| -> u16 { state_line(state).width() as u16 }; @@ -457,8 +496,12 @@ pub(crate) fn single_line_footer_layout( }; // Compute the width without going through `state_line` so we do not // depend on `default_state` (which may still be a queue variant). - let mode_only_width = - left_side_line(Some(collaboration_mode_indicator), mode_only_state).width() as u16; + let mode_only_width = left_side_line( + Some(collaboration_mode_indicator), + mode_only_state, + key_hints, + ) + .width() as u16; if !context_requires_cycle_hint && can_show_left_with_context(area, mode_only_width, context_width) { @@ -466,6 +509,7 @@ pub(crate) fn single_line_footer_layout( SummaryLeft::Custom(left_side_line( Some(collaboration_mode_indicator), mode_only_state, + key_hints, )), true, // show_context ); @@ -475,6 +519,7 @@ pub(crate) fn single_line_footer_layout( SummaryLeft::Custom(left_side_line( Some(collaboration_mode_indicator), mode_only_state, + key_hints, )), false, // show_context ); @@ -637,6 +682,7 @@ fn footer_from_props_lines( show_shortcuts_hint: bool, show_queue_hint: bool, ) -> Vec> { + let key_hints = props.key_hints; // Passive footer context can come from the configurable status line, the // active agent label, or both combined. if let Some(status_line) = passive_footer_status_line(props) { @@ -656,7 +702,11 @@ fn footer_from_props_lines( }, show_cycle_hint, }; - vec![left_side_line(collaboration_mode_indicator, state)] + vec![left_side_line( + collaboration_mode_indicator, + state, + key_hints, + )] } FooterMode::ShortcutOverlay => { let state = ShortcutsState { @@ -664,6 +714,7 @@ fn footer_from_props_lines( esc_backtrack_hint: props.esc_backtrack_hint, is_wsl: props.is_wsl, collaboration_modes_enabled: props.collaboration_modes_enabled, + key_hints, }; shortcut_overlay_lines(state) } @@ -679,7 +730,11 @@ fn footer_from_props_lines( }, show_cycle_hint, }; - vec![left_side_line(collaboration_mode_indicator, state)] + vec![left_side_line( + collaboration_mode_indicator, + state, + key_hints, + )] } } } @@ -781,6 +836,7 @@ struct ShortcutsState { esc_backtrack_hint: bool, is_wsl: bool, collaboration_modes_enabled: bool, + key_hints: FooterKeyHints, } fn quit_shortcut_reminder_line(key: KeyBinding) -> Line<'static> { @@ -856,10 +912,15 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { if change_mode.width() > 0 { ordered.push(change_mode); } - ordered.push(Line::from("")); ordered.push(show_transcript); - build_columns(ordered) + let mut lines = build_columns(ordered); + lines.push(Line::from("")); + lines.push(Line::from(vec![ + "customize shortcuts with ".into(), + "/keymap".cyan(), + ])); + lines } fn build_columns(entries: Vec>) -> Vec> { @@ -987,8 +1048,23 @@ impl ShortcutDescriptor { } fn overlay_entry(&self, state: ShortcutsState) -> Option> { - let binding = self.binding_for(state)?; - let mut line = Line::from(vec![self.prefix.into(), binding.key.into()]); + let key = match self.id { + ShortcutId::InsertNewline => state.key_hints.insert_newline, + ShortcutId::QueueMessageTab => state.key_hints.queue, + ShortcutId::ExternalEditor => state.key_hints.external_editor, + ShortcutId::EditPrevious => state.key_hints.edit_previous, + ShortcutId::ShowTranscript => state.key_hints.show_transcript, + ShortcutId::HistorySearch => state.key_hints.history_search, + ShortcutId::ReasoningDown => state.key_hints.reasoning_down, + ShortcutId::ReasoningUp => state.key_hints.reasoning_up, + ShortcutId::Commands + | ShortcutId::ShellCommands + | ShortcutId::FilePaths + | ShortcutId::PasteImage + | ShortcutId::Quit + | ShortcutId::ChangeMode => self.binding_for(state).map(|binding| binding.key), + }?; + let mut line = Line::from(vec![self.prefix.into(), key.into()]); match self.id { ShortcutId::EditPrevious => { if state.esc_backtrack_hint { @@ -996,7 +1072,7 @@ impl ShortcutDescriptor { } else { line.extend(vec![ " ".into(), - key_hint::plain(KeyCode::Esc).into(), + key.into(), " to edit previous message".into(), ]); } @@ -1287,6 +1363,7 @@ mod tests { show_cycle_hint, show_shortcuts_hint, show_queue_hint, + props.key_hints, ); match summary_left { SummaryLeft::Default => { @@ -1374,6 +1451,7 @@ mod tests { context_window_used_tokens: None, status_line_value: None, status_line_enabled: false, + key_hints: FooterKeyHints::default_bindings(), active_agent_label: None, }, ); @@ -1392,6 +1470,10 @@ mod tests { context_window_used_tokens: None, status_line_value: None, status_line_enabled: false, + key_hints: FooterKeyHints { + insert_newline: Some(key_hint::shift(KeyCode::Enter)), + ..FooterKeyHints::default_bindings() + }, active_agent_label: None, }, ); @@ -1410,6 +1492,7 @@ mod tests { context_window_used_tokens: None, status_line_value: None, status_line_enabled: false, + key_hints: FooterKeyHints::default_bindings(), active_agent_label: None, }, ); @@ -1428,6 +1511,7 @@ mod tests { context_window_used_tokens: None, status_line_value: None, status_line_enabled: false, + key_hints: FooterKeyHints::default_bindings(), active_agent_label: None, }, ); @@ -1446,6 +1530,7 @@ mod tests { context_window_used_tokens: None, status_line_value: None, status_line_enabled: false, + key_hints: FooterKeyHints::default_bindings(), active_agent_label: None, }, ); @@ -1464,6 +1549,7 @@ mod tests { context_window_used_tokens: None, status_line_value: None, status_line_enabled: false, + key_hints: FooterKeyHints::default_bindings(), active_agent_label: None, }, ); @@ -1482,6 +1568,7 @@ mod tests { context_window_used_tokens: None, status_line_value: None, status_line_enabled: false, + key_hints: FooterKeyHints::default_bindings(), active_agent_label: None, }, ); @@ -1500,6 +1587,7 @@ mod tests { context_window_used_tokens: None, status_line_value: None, status_line_enabled: false, + key_hints: FooterKeyHints::default_bindings(), active_agent_label: None, }, ); @@ -1518,6 +1606,7 @@ mod tests { context_window_used_tokens: Some(123_456), status_line_value: None, status_line_enabled: false, + key_hints: FooterKeyHints::default_bindings(), active_agent_label: None, }, ); @@ -1536,6 +1625,7 @@ mod tests { context_window_used_tokens: None, status_line_value: None, status_line_enabled: false, + key_hints: FooterKeyHints::default_bindings(), active_agent_label: None, }, ); @@ -1552,6 +1642,7 @@ mod tests { context_window_used_tokens: None, status_line_value: None, status_line_enabled: false, + key_hints: FooterKeyHints::default_bindings(), active_agent_label: None, }; @@ -1581,6 +1672,7 @@ mod tests { context_window_used_tokens: None, status_line_value: None, status_line_enabled: false, + key_hints: FooterKeyHints::default_bindings(), active_agent_label: None, }; @@ -1603,6 +1695,7 @@ mod tests { context_window_used_tokens: None, status_line_value: Some(Line::from("Status line content".to_string())), status_line_enabled: true, + key_hints: FooterKeyHints::default_bindings(), active_agent_label: None, }; @@ -1620,6 +1713,7 @@ mod tests { context_window_used_tokens: None, status_line_value: Some(Line::from("Status line content".to_string())), status_line_enabled: true, + key_hints: FooterKeyHints::default_bindings(), active_agent_label: None, }; @@ -1637,6 +1731,7 @@ mod tests { context_window_used_tokens: None, status_line_value: Some(Line::from("Status line content".to_string())), status_line_enabled: true, + key_hints: FooterKeyHints::default_bindings(), active_agent_label: None, }; @@ -1654,6 +1749,7 @@ mod tests { context_window_used_tokens: None, status_line_value: None, // command timed out / empty status_line_enabled: true, + key_hints: FooterKeyHints::default_bindings(), active_agent_label: None, }; @@ -1676,6 +1772,7 @@ mod tests { context_window_used_tokens: None, status_line_value: None, status_line_enabled: false, + key_hints: FooterKeyHints::default_bindings(), active_agent_label: None, }; @@ -1698,6 +1795,7 @@ mod tests { context_window_used_tokens: None, status_line_value: None, status_line_enabled: true, + key_hints: FooterKeyHints::default_bindings(), active_agent_label: None, }; @@ -1723,6 +1821,7 @@ mod tests { "Status line content that should truncate before the mode indicator".to_string(), )), status_line_enabled: true, + key_hints: FooterKeyHints::default_bindings(), active_agent_label: None, }; @@ -1745,6 +1844,7 @@ mod tests { context_window_used_tokens: None, status_line_value: None, status_line_enabled: false, + key_hints: FooterKeyHints::default_bindings(), active_agent_label: Some("Robie [explorer]".to_string()), }; @@ -1762,6 +1862,7 @@ mod tests { context_window_used_tokens: None, status_line_value: Some(Line::from("Status line content".to_string())), status_line_enabled: true, + key_hints: FooterKeyHints::default_bindings(), active_agent_label: Some("Robie [explorer]".to_string()), }; @@ -1785,6 +1886,7 @@ mod tests { .to_string(), )), status_line_enabled: true, + key_hints: FooterKeyHints::default_bindings(), active_agent_label: None, }; @@ -1838,6 +1940,7 @@ mod tests { esc_backtrack_hint: false, is_wsl, collaboration_modes_enabled: false, + key_hints: FooterKeyHints::default_bindings(), }) .expect("shortcut binding") .key; diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index 4262e4fdec..9a9ffc6abf 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -16,6 +16,8 @@ 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::key_hint::KeyBindingListExt; +use crate::keymap::ListKeymap; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; @@ -265,6 +267,7 @@ pub(crate) struct ListSelectionView { /// Called when the picker is dismissed via Esc/Ctrl+C without selecting. on_cancel: OnCancelCallback, + keymap: ListKeymap, } impl ListSelectionView { @@ -275,7 +278,11 @@ impl ListSelectionView { /// When search is enabled, rows without `search_value` will disappear as /// soon as the query is non-empty, which can look like dropped data unless /// callers intentionally populate that field. - pub fn new(params: SelectionViewParams, app_event_tx: AppEventSender) -> Self { + pub fn new( + params: SelectionViewParams, + app_event_tx: AppEventSender, + keymap: ListKeymap, + ) -> Self { let mut header = params.header; if params.title.is_some() || params.subtitle.is_some() { let title = params.title.map(|title| Line::from(title.bold())); @@ -330,6 +337,7 @@ impl ListSelectionView { preserve_side_content_bg: params.preserve_side_content_bg, on_selection_changed: params.on_selection_changed, on_cancel: params.on_cancel, + keymap, }; s.apply_filter(); if s.tabs_enabled() && !has_initial_selected_idx && s.state.selected_idx.is_none() { @@ -800,23 +808,25 @@ impl ListSelectionView { impl BottomPaneView for ListSelectionView { fn handle_key_event(&mut self, key_event: KeyEvent) { - match key_event { - // Some terminals (or configurations) send Control key chords as - // C0 control characters without reporting the CONTROL modifier. - // Handle fallbacks for Ctrl-P/N here so navigation works everywhere. + let is_plain_text_char = matches!( + key_event, KeyEvent { - code: KeyCode::Up, .. - } - | KeyEvent { - code: KeyCode::Char('p'), - modifiers: KeyModifiers::CONTROL, + code: KeyCode::Char(ch), + modifiers, .. + } if !ch.is_ascii_control() + && !modifiers.contains(KeyModifiers::CONTROL) + && !modifiers.contains(KeyModifiers::ALT) + ); + let allow_plain_char_navigation = !self.is_searchable || !is_plain_text_char; + + match key_event { + _ if allow_plain_char_navigation && self.keymap.move_up.is_pressed(key_event) => { + self.move_up() + } + _ if allow_plain_char_navigation && self.keymap.move_down.is_pressed(key_event) => { + self.move_down() } - | KeyEvent { - code: KeyCode::Char('\u{0010}'), - modifiers: KeyModifiers::NONE, - .. - } /* ^P */ => self.move_up(), KeyEvent { code: KeyCode::Left, .. @@ -825,30 +835,6 @@ impl BottomPaneView for ListSelectionView { code: KeyCode::Right, .. } if self.tabs_enabled() => self.switch_tab(/*step*/ 1), - KeyEvent { - code: KeyCode::Char('k'), - modifiers: KeyModifiers::NONE, - .. - } if !self.is_searchable => self.move_up(), - KeyEvent { - code: KeyCode::Down, - .. - } - | KeyEvent { - code: KeyCode::Char('n'), - modifiers: KeyModifiers::CONTROL, - .. - } - | KeyEvent { - code: KeyCode::Char('\u{000e}'), - modifiers: KeyModifiers::NONE, - .. - } /* ^N */ => self.move_down(), - KeyEvent { - code: KeyCode::Char('j'), - modifiers: KeyModifiers::NONE, - .. - } if !self.is_searchable => self.move_down(), KeyEvent { code: KeyCode::Backspace, .. @@ -872,11 +858,14 @@ impl BottomPaneView for ListSelectionView { } if self.is_searchable && self.search_query.is_empty() && self.selected_item_has_toggle_placeholder() => {} - KeyEvent { - code: KeyCode::Esc, .. - } => { + _ if self.keymap.cancel.is_pressed(key_event) => { self.on_ctrl_c(); } + _ if self.keymap.accept.is_pressed(key_event) => self.accept(), + KeyEvent { + code: KeyCode::Char(c), + .. + } if c.is_ascii_control() => {} KeyEvent { code: KeyCode::Char(c), modifiers, @@ -914,11 +903,6 @@ impl BottomPaneView for ListSelectionView { self.accept(); } } - KeyEvent { - code: KeyCode::Enter, - modifiers: KeyModifiers::NONE, - .. - } => self.accept(), _ => {} } } @@ -951,6 +935,10 @@ impl BottomPaneView for ListSelectionView { ListSelectionView::active_tab_id(self) } + fn prefer_esc_to_handle_key_event(&self) -> bool { + true + } + fn on_ctrl_c(&mut self) -> CancellationEvent { if let Some(cb) = &self.on_cancel { cb(&self.app_event_tx); @@ -1252,6 +1240,8 @@ mod tests { use crate::app_event::AppEvent; use crate::bottom_pane::popup_consts::standard_popup_hint_line; use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; use insta::assert_snapshot; use pretty_assertions::assert_eq; use ratatui::buffer::Buffer; @@ -1303,6 +1293,10 @@ mod tests { } } + fn new_view(params: SelectionViewParams, tx: AppEventSender) -> ListSelectionView { + ListSelectionView::new(params, tx, crate::keymap::RuntimeKeymap::defaults().list) + } + fn make_selection_view(subtitle: Option<&str>) -> ListSelectionView { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); @@ -1322,7 +1316,7 @@ mod tests { ..Default::default() }, ]; - ListSelectionView::new( + new_view( SelectionViewParams { title: Some("Select Approval Mode".to_string()), subtitle: subtitle.map(str::to_string), @@ -1402,6 +1396,7 @@ mod tests { ..Default::default() }, tx, + crate::keymap::RuntimeKeymap::defaults().list, ); let before_scroll = render_lines_with_width(&view, width); @@ -1439,7 +1434,7 @@ mod tests { Some(&codex_home), Some(94), ); - let view = ListSelectionView::new(params, tx); + let view = new_view(params, tx); let rendered = render_lines_in_area(&view, /*width*/ 94, /*height*/ 35); assert!(rendered.contains("Move up/down to live preview themes")); @@ -1481,6 +1476,7 @@ mod tests { ..Default::default() }, tx, + crate::keymap::RuntimeKeymap::defaults().list, ); let area = Rect::new(0, 0, 120, 35); let mut buf = Buffer::empty(area); @@ -1517,7 +1513,7 @@ mod tests { "Use /setup-default-sandbox".cyan(), " to allow network access.".dim(), ]); - let view = ListSelectionView::new( + let view = new_view( SelectionViewParams { title: Some("Select Approval Mode".to_string()), footer_note: Some(footer_note), @@ -1544,7 +1540,7 @@ mod tests { dismiss_on_select: true, ..Default::default() }]; - let mut view = ListSelectionView::new( + let mut view = new_view( SelectionViewParams { title: Some("Select Approval Mode".to_string()), footer_hint: Some(standard_popup_hint_line()), @@ -1597,6 +1593,7 @@ mod tests { ..Default::default() }, tx, + crate::keymap::RuntimeKeymap::defaults().list, ); view.set_search_query("beta".to_string()); @@ -1659,6 +1656,7 @@ mod tests { ..Default::default() }, tx, + crate::keymap::RuntimeKeymap::defaults().list, ); assert_eq!(view.active_tab_id(), Some("beta")); @@ -1690,6 +1688,7 @@ mod tests { ..Default::default() }, tx, + crate::keymap::RuntimeKeymap::defaults().list, ); view.set_search_query("plugin".to_string()); @@ -1729,6 +1728,7 @@ mod tests { ..Default::default() }, tx, + crate::keymap::RuntimeKeymap::defaults().list, ); let (wrapped_tx_raw, _wrapped_rx) = unbounded_channel::(); let wrapped_tx = AppEventSender::new(wrapped_tx_raw); @@ -1747,6 +1747,7 @@ mod tests { ..Default::default() }, wrapped_tx, + crate::keymap::RuntimeKeymap::defaults().list, ); let rendered = render_lines_with_width(&single_line_view, /*width*/ 36); @@ -1802,6 +1803,7 @@ mod tests { ..Default::default() }, auto_tx, + crate::keymap::RuntimeKeymap::defaults().list, ); let (widened_tx_raw, _widened_rx) = unbounded_channel::(); let widened_tx = AppEventSender::new(widened_tx_raw); @@ -1814,6 +1816,7 @@ mod tests { ..Default::default() }, widened_tx, + crate::keymap::RuntimeKeymap::defaults().list, ); let auto_rendered = render_lines_with_width(&auto_view, /*width*/ 48); @@ -1845,6 +1848,7 @@ mod tests { ..Default::default() }, tx, + crate::keymap::RuntimeKeymap::defaults().list, ); view.set_search_query("no-matches".to_string()); @@ -1875,6 +1879,7 @@ mod tests { ..Default::default() }, tx, + crate::keymap::RuntimeKeymap::defaults().list, ); while rx.try_recv().is_ok() {} @@ -1921,6 +1926,7 @@ mod tests { ..Default::default() }, tx, + crate::keymap::RuntimeKeymap::defaults().list, ); assert_eq!(view.selected_actual_idx(), Some(1)); @@ -1944,6 +1950,100 @@ mod tests { assert_eq!(view.take_last_selected_index(), Some(3)); } + #[test] + fn c0_ctrl_p_respects_unbound_list_move_up() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut keymap = crate::keymap::RuntimeKeymap::defaults().list; + keymap.move_up.clear(); + let mut view = ListSelectionView::new( + SelectionViewParams { + items: vec![ + SelectionItem { + name: "First".to_string(), + ..Default::default() + }, + SelectionItem { + name: "Second".to_string(), + ..Default::default() + }, + ], + initial_selected_idx: Some(1), + is_searchable: true, + ..Default::default() + }, + tx, + keymap, + ); + + view.handle_key_event(KeyEvent::new(KeyCode::Char('\u{0010}'), KeyModifiers::NONE)); + + assert_eq!(view.selected_actual_idx(), Some(1)); + assert_eq!(view.search_query, ""); + } + + #[test] + fn c0_ctrl_n_respects_unbound_list_move_down() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut keymap = crate::keymap::RuntimeKeymap::defaults().list; + keymap.move_down.clear(); + let mut view = ListSelectionView::new( + SelectionViewParams { + items: vec![ + SelectionItem { + name: "First".to_string(), + ..Default::default() + }, + SelectionItem { + name: "Second".to_string(), + ..Default::default() + }, + ], + is_searchable: true, + ..Default::default() + }, + tx, + keymap, + ); + + view.handle_key_event(KeyEvent::new(KeyCode::Char('\u{000e}'), KeyModifiers::NONE)); + + assert_eq!(view.selected_actual_idx(), Some(0)); + assert_eq!(view.search_query, ""); + } + + #[test] + fn c0_ctrl_p_respects_remapped_list_move_down() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut keymap = crate::keymap::RuntimeKeymap::defaults().list; + keymap.move_up.clear(); + keymap.move_down = vec![crate::key_hint::ctrl(KeyCode::Char('p'))]; + let mut view = ListSelectionView::new( + SelectionViewParams { + items: vec![ + SelectionItem { + name: "First".to_string(), + ..Default::default() + }, + SelectionItem { + name: "Second".to_string(), + ..Default::default() + }, + ], + is_searchable: true, + ..Default::default() + }, + tx, + keymap, + ); + + view.handle_key_event(KeyEvent::new(KeyCode::Char('\u{0010}'), KeyModifiers::NONE)); + + assert_eq!(view.selected_actual_idx(), Some(1)); + } + #[test] fn wraps_long_option_without_overflowing_columns() { let (tx_raw, _rx) = unbounded_channel::(); @@ -1960,7 +2060,7 @@ mod tests { ..Default::default() }, ]; - let view = ListSelectionView::new( + let view = new_view( SelectionViewParams { title: Some("Approval".to_string()), items, @@ -2018,7 +2118,7 @@ mod tests { ..Default::default() }, ]; - let view = ListSelectionView::new( + let view = new_view( SelectionViewParams { title: Some("Select Model and Effort".to_string()), items, @@ -2052,7 +2152,7 @@ mod tests { ..Default::default() }) .collect(); - let view = ListSelectionView::new( + let view = new_view( SelectionViewParams { title: Some("Debug".to_string()), items, @@ -2100,7 +2200,7 @@ mod tests { ..Default::default() }, ]; - let view = ListSelectionView::new( + let view = new_view( SelectionViewParams { title: Some("Select Model and Effort".to_string()), items, @@ -2127,7 +2227,7 @@ mod tests { ..Default::default() }) .collect(); - let view = ListSelectionView::new( + let view = new_view( SelectionViewParams { title: Some("Debug".to_string()), items, @@ -2178,6 +2278,7 @@ mod tests { ..Default::default() }, tx, + crate::keymap::RuntimeKeymap::defaults().list, ); let before_scroll = render_lines_with_width(&view, /*width*/ 96); @@ -2212,6 +2313,7 @@ mod tests { ..Default::default() }, tx, + crate::keymap::RuntimeKeymap::defaults().list, ); let before_scroll = render_lines_with_width(&view, width); @@ -2253,6 +2355,7 @@ mod tests { ..Default::default() }, tx, + crate::keymap::RuntimeKeymap::defaults().list, ); let content_width: u16 = 120; @@ -2280,6 +2383,7 @@ mod tests { ..Default::default() }, tx, + crate::keymap::RuntimeKeymap::defaults().list, ); assert_eq!(view.side_layout_width(/*content_width*/ 80), None); @@ -2310,6 +2414,7 @@ mod tests { ..Default::default() }, tx, + crate::keymap::RuntimeKeymap::defaults().list, ); let rendered = render_lines_with_width(&view, /*width*/ 70); @@ -2344,6 +2449,7 @@ mod tests { ..Default::default() }, tx, + crate::keymap::RuntimeKeymap::defaults().list, ); let width = 120; @@ -2403,6 +2509,7 @@ mod tests { ..Default::default() }, tx, + crate::keymap::RuntimeKeymap::defaults().list, ); let width = 120; diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index a779a6eeb5..d2b065dfae 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -24,11 +24,12 @@ use crate::bottom_pane::pending_thread_approvals::PendingThreadApprovals; use crate::bottom_pane::unified_exec_footer::UnifiedExecFooter; use crate::key_hint; use crate::key_hint::KeyBinding; +use crate::keymap::RuntimeKeymap; use crate::render::renderable::FlexRenderable; use crate::render::renderable::Renderable; use crate::render::renderable::RenderableItem; use crate::tui::FrameRequester; -use bottom_pane_view::BottomPaneView; +pub(crate) use bottom_pane_view::BottomPaneView; use bottom_pane_view::ViewCompletion; use codex_core_skills::model::SkillMetadata; use codex_features::Features; @@ -99,6 +100,8 @@ pub(crate) use footer::GoalStatusIndicator; #[cfg(test)] pub(crate) use footer::goal_status_indicator_line; pub(crate) use list_selection_view::ColumnWidthMode; +#[cfg(test)] +pub(crate) use list_selection_view::ListSelectionView; pub(crate) use list_selection_view::SelectionRowDisplay; pub(crate) use list_selection_view::SelectionToggle; pub(crate) use list_selection_view::SelectionViewParams; @@ -220,6 +223,7 @@ pub(crate) struct BottomPane { pending_thread_approvals: PendingThreadApprovals, context_window_percent: Option, context_window_used_tokens: Option, + keymap: RuntimeKeymap, } pub(crate) struct BottomPaneParams { @@ -253,6 +257,8 @@ impl BottomPane { disable_paste_burst, ); composer.set_frame_requester(frame_requester.clone()); + let keymap = RuntimeKeymap::defaults(); + composer.set_keymap_bindings(&keymap); composer.set_skill_mentions(skills); Self { composer, @@ -273,6 +279,7 @@ impl BottomPane { animations_enabled, context_window_percent: None, context_window_used_tokens: None, + keymap, } } @@ -320,6 +327,19 @@ impl BottomPane { self.composer.record_pending_slash_command_history(); } + /// Replace all bottom-pane keymap caches from one resolved runtime keymap. + /// + /// The bottom pane owns several input surfaces: composer, overlays, and + /// selection views. Applying one snapshot through this method keeps those + /// surfaces synchronized after config reloads or interactive remaps. Callers + /// should not update the composer directly unless they deliberately want + /// overlays and selection views to continue using the previous bindings. + pub fn set_keymap_bindings(&mut self, keymap: &RuntimeKeymap) { + self.keymap = keymap.clone(); + self.composer.set_keymap_bindings(keymap); + self.request_redraw(); + } + /// Clear pending attachments and mention bindings e.g. when a slash command doesn't submit text. pub(crate) fn drain_pending_submission_state(&mut self) { let _ = self.take_recent_submission_images_with_placeholders(); @@ -393,7 +413,7 @@ impl BottomPane { /// Update the key hint shown next to queued messages so it matches the /// binding that `ChatWidget` actually listens for. - pub(crate) fn set_queued_message_edit_binding(&mut self, binding: KeyBinding) { + pub(crate) fn set_queued_message_edit_binding(&mut self, binding: Option) { self.pending_input_preview.set_edit_binding(binding); self.request_redraw(); } @@ -491,8 +511,13 @@ impl BottomPane { let Some(first) = self.delayed_approval_requests.pop_front() else { return; }; - let mut modal = - ApprovalOverlay::new(first.request, self.app_event_tx.clone(), first.features); + let mut modal = ApprovalOverlay::new( + first.request, + self.app_event_tx.clone(), + first.features, + self.keymap.approval.clone(), + self.keymap.list.clone(), + ); while let Some(delayed) = self.delayed_approval_requests.pop_back() { modal.enqueue_request(delayed.request); } @@ -933,16 +958,32 @@ impl BottomPane { } /// Show a generic list selection view with the provided items. - pub(crate) fn show_selection_view(&mut self, params: list_selection_view::SelectionViewParams) { - let view = list_selection_view::ListSelectionView::new(params, self.app_event_tx.clone()); + pub(crate) fn show_selection_view( + &mut self, + mut params: list_selection_view::SelectionViewParams, + ) { + self.apply_standard_popup_hint(&mut params); + let view = list_selection_view::ListSelectionView::new( + params, + self.app_event_tx.clone(), + self.keymap.list.clone(), + ); self.push_view(Box::new(view)); } + fn apply_standard_popup_hint(&self, params: &mut list_selection_view::SelectionViewParams) { + if params.footer_hint.is_none() + || params.footer_hint.as_ref() == Some(&popup_consts::standard_popup_hint_line()) + { + params.footer_hint = Some(self.standard_popup_hint_line()); + } + } + /// Replace the active selection view when it matches `view_id`. pub(crate) fn replace_selection_view_if_active( &mut self, view_id: &'static str, - params: list_selection_view::SelectionViewParams, + mut params: list_selection_view::SelectionViewParams, ) -> bool { let is_match = self .view_stack @@ -953,7 +994,50 @@ impl BottomPane { } self.view_stack.pop(); - let view = list_selection_view::ListSelectionView::new(params, self.app_event_tx.clone()); + self.apply_standard_popup_hint(&mut params); + let view = list_selection_view::ListSelectionView::new( + params, + self.app_event_tx.clone(), + self.keymap.list.clone(), + ); + self.push_view(Box::new(view)); + true + } + + pub(crate) fn standard_popup_hint_line(&self) -> Line<'static> { + popup_consts::standard_popup_hint_line_for_keymap(&self.keymap.list) + } + + /// Replace one or more active views whose IDs are in `view_ids` with a + /// generic list selection view. + pub(crate) fn replace_active_views_with_selection_view( + &mut self, + view_ids: &[&'static str], + mut params: list_selection_view::SelectionViewParams, + ) -> bool { + let is_match = self + .view_stack + .last() + .and_then(|view| view.view_id()) + .is_some_and(|view_id| view_ids.contains(&view_id)); + if !is_match { + return false; + } + + while self + .view_stack + .last() + .and_then(|view| view.view_id()) + .is_some_and(|view_id| view_ids.contains(&view_id)) + { + self.view_stack.pop(); + } + self.apply_standard_popup_hint(&mut params); + let view = list_selection_view::ListSelectionView::new( + params, + self.app_event_tx.clone(), + self.keymap.list.clone(), + ); self.push_view(Box::new(view)); true } @@ -1031,11 +1115,15 @@ impl BottomPane { .is_some_and(bottom_pane_view::BottomPaneView::terminal_title_requires_action) } - #[cfg(test)] pub(crate) fn has_active_view(&self) -> bool { !self.view_stack.is_empty() } + #[cfg(test)] + pub(crate) fn active_view_id(&self) -> Option<&'static str> { + self.view_stack.last().and_then(|view| view.view_id()) + } + /// Return true when the pane is in the regular composer state without any /// overlays or popups and not running a task. This is the safe context to /// use Esc-Esc for backtracking from the main view. @@ -1087,7 +1175,13 @@ impl BottomPane { self.maybe_show_delayed_approval_requests_at(now); } else { // No recent composer activity, so show the approval modal immediately. - let modal = ApprovalOverlay::new(request, self.app_event_tx.clone(), features.clone()); + let modal = ApprovalOverlay::new( + request, + self.app_event_tx.clone(), + features.clone(), + self.keymap.approval.clone(), + self.keymap.list.clone(), + ); self.pause_status_timer_for_modal(); self.push_view(Box::new(modal)); } @@ -2414,6 +2508,37 @@ mod tests { ); } + #[test] + fn selection_view_esc_respects_remapped_list_cancel() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = test_pane(tx); + let mut keymap = RuntimeKeymap::defaults(); + keymap.list.cancel = vec![crate::key_hint::plain(KeyCode::Char('q'))]; + pane.set_keymap_bindings(&keymap); + pane.show_selection_view(SelectionViewParams { + title: Some("Agents".to_string()), + items: vec![SelectionItem { + name: "Main".to_string(), + ..Default::default() + }], + on_cancel: Some(Box::new(|tx: &_| { + tx.send(AppEvent::OpenApprovalsPopup); + })), + ..Default::default() + }); + + pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert!(pane.active_view().is_some()); + assert!(rx.try_recv().is_err()); + + pane.handle_key_event(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)); + + assert!(pane.no_modal_or_popup_active()); + assert!(matches!(rx.try_recv(), Ok(AppEvent::OpenApprovalsPopup))); + } + #[test] fn esc_routes_to_handle_key_event_when_requested() { #[derive(Default)] diff --git a/codex-rs/tui/src/bottom_pane/pending_input_preview.rs b/codex-rs/tui/src/bottom_pane/pending_input_preview.rs index a393af08de..34e7d7d390 100644 --- a/codex-rs/tui/src/bottom_pane/pending_input_preview.rs +++ b/codex-rs/tui/src/bottom_pane/pending_input_preview.rs @@ -26,7 +26,7 @@ pub(crate) struct PendingInputPreview { pub queued_messages: Vec, /// Key combination rendered in the hint line. Defaults to Alt+Up but may /// be overridden for terminals where that chord is unavailable. - edit_binding: key_hint::KeyBinding, + edit_binding: Option, } const PREVIEW_LINE_LIMIT: usize = 3; @@ -37,14 +37,14 @@ impl PendingInputPreview { pending_steers: Vec::new(), rejected_steers: Vec::new(), queued_messages: Vec::new(), - edit_binding: key_hint::alt(KeyCode::Up), + edit_binding: Some(key_hint::alt(KeyCode::Up)), } } /// Replace the keybinding shown in the hint line at the bottom of the /// queued-messages list. The caller is responsible for also wiring the /// corresponding key event handler. - pub(crate) fn set_edit_binding(&mut self, binding: key_hint::KeyBinding) { + pub(crate) fn set_edit_binding(&mut self, binding: Option) { self.edit_binding = binding; } @@ -145,11 +145,13 @@ impl PendingInputPreview { } } - if !self.queued_messages.is_empty() { + if !self.queued_messages.is_empty() + && let Some(edit_binding) = self.edit_binding + { lines.push( Line::from(vec![ " ".into(), - self.edit_binding.into(), + edit_binding.into(), " edit last queued message".into(), ]) .dim(), @@ -208,7 +210,7 @@ mod tests { fn render_one_message_with_shift_left_binding() { let mut queue = PendingInputPreview::new(); queue.queued_messages.push("Hello, world!".to_string()); - queue.set_edit_binding(key_hint::shift(KeyCode::Left)); + queue.set_edit_binding(Some(key_hint::shift(KeyCode::Left))); let width = 40; let height = queue.desired_height(width); let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); diff --git a/codex-rs/tui/src/bottom_pane/popup_consts.rs b/codex-rs/tui/src/bottom_pane/popup_consts.rs index 2cabe389b1..d85b9c6385 100644 --- a/codex-rs/tui/src/bottom_pane/popup_consts.rs +++ b/codex-rs/tui/src/bottom_pane/popup_consts.rs @@ -1,9 +1,12 @@ //! Shared popup-related constants for bottom pane widgets. -use crossterm::event::KeyCode; use ratatui::text::Line; use crate::key_hint; +use crate::key_hint::KeyBinding; +use crate::keymap::ListKeymap; +use crate::keymap::primary_binding; +use crossterm::event::KeyCode; /// Maximum number of rows any popup should attempt to display. /// Keep this consistent across all popups for a uniform feel. @@ -19,3 +22,40 @@ pub(crate) fn standard_popup_hint_line() -> Line<'static> { " to go back".into(), ]) } + +pub(crate) fn standard_popup_hint_line_for_keymap(list_keymap: &ListKeymap) -> Line<'static> { + accept_cancel_hint_line( + primary_binding(&list_keymap.accept), + "to confirm", + primary_binding(&list_keymap.cancel), + "to go back", + ) +} + +pub(crate) fn accept_cancel_hint_line( + accept: Option, + accept_label: &'static str, + cancel: Option, + cancel_label: &'static str, +) -> Line<'static> { + match (accept, cancel) { + (Some(accept), Some(cancel)) => Line::from(vec![ + "Press ".into(), + accept.into(), + format!(" {accept_label} or ").into(), + cancel.into(), + format!(" {cancel_label}").into(), + ]), + (Some(accept), None) => Line::from(vec![ + "Press ".into(), + accept.into(), + format!(" {accept_label}").into(), + ]), + (None, Some(cancel)) => Line::from(vec![ + "Press ".into(), + cancel.into(), + format!(" {cancel_label}").into(), + ]), + (None, None) => Line::from(""), + } +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_permissions_prompt.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_permissions_prompt.snap index 391102e74f..f650e3c766 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_permissions_prompt.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_permissions_prompt.snap @@ -12,6 +12,6 @@ expression: "normalize_snapshot_paths(render_overlay_lines(&view, 120))" › 1. Yes, grant these permissions for this turn (y) 2. Yes, grant for this turn with strict auto review (r) 3. Yes, grant these permissions for this session (a) - 4. No, continue without permissions (n) + 4. No, continue without permissions (d) Press enter to confirm or esc to cancel diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap index 7ac7d41d49..b5508114fe 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap @@ -16,4 +16,6 @@ expression: terminal.backend() " ctrl + g to edit in external editor esc again to edit previous message " " ctrl + r search history ctrl + c to exit " " ⌥ + , reasoning down ⌥ + . reasoning up " -" ctrl + t to view transcript " +" ctrl + t to view transcript " +" " +" customize shortcuts with /keymap " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap index 080dc09709..dc2f896096 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap @@ -8,5 +8,6 @@ expression: terminal.backend() " ctrl + g to edit in external editor esc esc to edit previous message " " ctrl + r search history ctrl + c to exit " " ⌥ + , reasoning down ⌥ + . reasoning up " -" shift + tab to change mode " -" ctrl + t to view transcript " +" shift + tab to change mode ctrl + t to view transcript " +" " +" customize shortcuts with /keymap " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap index d1878e51f3..52c26826e9 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap @@ -8,4 +8,6 @@ expression: terminal.backend() " ctrl + g to edit in external editor esc again to edit previous message " " ctrl + r search history ctrl + c to exit " " ⌥ + , reasoning down ⌥ + . reasoning up " -" ctrl + t to view transcript " +" ctrl + t to view transcript " +" " +" customize shortcuts with /keymap " diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 2af4b3145f..7ab6d38fca 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -10,7 +10,10 @@ //! This module does not implement an Emacs-style multi-entry kill ring. It keeps only the most //! recent killed span. +use crate::key_hint::KeyBindingListExt; use crate::key_hint::is_altgr; +use crate::keymap::EditorKeymap; +use crate::keymap::RuntimeKeymap; use codex_protocol::user_input::ByteRange; use codex_protocol::user_input::TextElement as UserTextElement; use crossterm::event::KeyCode; @@ -92,6 +95,7 @@ pub(crate) struct TextArea { elements: Vec, next_element_id: u64, kill_buffer: String, + editor_keymap: EditorKeymap, } #[derive(Debug, Clone)] @@ -116,9 +120,20 @@ impl TextArea { elements: Vec::new(), next_element_id: 1, kill_buffer: String::new(), + editor_keymap: RuntimeKeymap::defaults().editor, } } + /// Replace the editor keymap used by subsequent text-editing input. + /// + /// This method intentionally swaps only the keymap cache. It does not + /// reinterpret pending input, move the cursor, or mutate the kill buffer, so + /// callers can safely apply a live config update while preserving the + /// current draft exactly as typed. + pub fn set_keymap_bindings(&mut self, keymap: &EditorKeymap) { + self.editor_keymap = keymap.clone(); + } + /// Replace the visible textarea text and clear any existing text elements. /// /// This is the "fresh buffer" path for callers that want plain text with no placeholder @@ -319,243 +334,123 @@ impl TextArea { if !matches!(event.kind, KeyEventKind::Press | KeyEventKind::Repeat) { return; } - match event { - // Some terminals (or configurations) send Control key chords as - // C0 control characters without reporting the CONTROL modifier. - // Handle common fallbacks for Ctrl-B/F/P/N here so they don't get - // inserted as literal control bytes. - KeyEvent { code: KeyCode::Char('\u{0002}'), modifiers: KeyModifiers::NONE, .. } /* ^B */ => { - self.move_cursor_left(); - } - KeyEvent { code: KeyCode::Char('\u{0006}'), modifiers: KeyModifiers::NONE, .. } /* ^F */ => { - self.move_cursor_right(); - } - KeyEvent { code: KeyCode::Char('\u{0010}'), modifiers: KeyModifiers::NONE, .. } /* ^P */ => { - self.move_cursor_up(); - } - KeyEvent { code: KeyCode::Char('\u{000e}'), modifiers: KeyModifiers::NONE, .. } /* ^N */ => { - self.move_cursor_down(); - } - KeyEvent { - code: KeyCode::Char(c), - // Insert plain characters (and Shift-modified). Do NOT insert when ALT is held, - // because many terminals map Option/Meta combos to ALT+ (e.g. ESC f/ESC b) - // for word navigation. Those are handled explicitly below. - modifiers: KeyModifiers::NONE | KeyModifiers::SHIFT, - .. - } => self.insert_str(&c.to_string()), - KeyEvent { - code: KeyCode::Char('j' | 'm'), - modifiers: KeyModifiers::CONTROL, - .. - } - | KeyEvent { - code: KeyCode::Enter, - .. - } => self.insert_str("\n"), - KeyEvent { - code: KeyCode::Char('h'), - modifiers, - .. - } if modifiers == (KeyModifiers::CONTROL | KeyModifiers::ALT) => { - self.delete_backward_word() - }, - // Windows AltGr generates ALT|CONTROL; treat as a plain character input unless - // we match a specific Control+Alt binding above. - KeyEvent { - code: KeyCode::Char(c), - modifiers, - .. - } if is_altgr(modifiers) => self.insert_str(&c.to_string()), - KeyEvent { - code: KeyCode::Backspace, - modifiers: KeyModifiers::ALT, - .. - } => self.delete_backward_word(), - KeyEvent { - code: KeyCode::Backspace, - .. - } - | KeyEvent { - code: KeyCode::Char('h'), - modifiers: KeyModifiers::CONTROL, - .. - } => self.delete_backward(/*n*/ 1), - KeyEvent { - code: KeyCode::Delete, - modifiers: KeyModifiers::ALT, - .. - } - | KeyEvent { - code: KeyCode::Char('d'), - modifiers: KeyModifiers::ALT, - .. - } => self.delete_forward_word(), - KeyEvent { - code: KeyCode::Delete, - .. - } - | KeyEvent { - code: KeyCode::Char('d'), - modifiers: KeyModifiers::CONTROL, - .. - } => self.delete_forward(/*n*/ 1), + let keymap = self.editor_keymap.clone(); + self.input_with_keymap(event, &keymap); + } - KeyEvent { - code: KeyCode::Char('w'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - self.delete_backward_word(); - } - // Meta-b -> move to beginning of previous word - // Meta-f -> move to end of next word - // Many terminals map Option (macOS) to Alt. Some send Alt|Shift, so match contains(ALT). - KeyEvent { - code: KeyCode::Char('b'), - modifiers: KeyModifiers::ALT, - .. - } => { - self.set_cursor(self.beginning_of_previous_word()); - } - KeyEvent { - code: KeyCode::Char('f'), - modifiers: KeyModifiers::ALT, - .. - } => { - self.set_cursor(self.end_of_next_word()); - } - KeyEvent { - code: KeyCode::Char('u'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - self.kill_to_beginning_of_line(); - } - KeyEvent { - code: KeyCode::Char('k'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - self.kill_to_end_of_line(); - } - KeyEvent { - code: KeyCode::Char('y'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - self.yank(); - } - - // Cursor movement - KeyEvent { - code: KeyCode::Left, - modifiers: KeyModifiers::NONE, - .. - } => { - self.move_cursor_left(); - } - KeyEvent { - code: KeyCode::Right, - modifiers: KeyModifiers::NONE, - .. - } => { - self.move_cursor_right(); - } - KeyEvent { - code: KeyCode::Char('b'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - self.move_cursor_left(); - } - KeyEvent { - code: KeyCode::Char('f'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - self.move_cursor_right(); - } - KeyEvent { - code: KeyCode::Char('p'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - self.move_cursor_up(); - } - KeyEvent { - code: KeyCode::Char('n'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - self.move_cursor_down(); - } - // Some terminals send Alt+Arrow for word-wise movement: - // Option/Left -> Alt+Left (previous word start) - // Option/Right -> Alt+Right (next word end) - KeyEvent { - code: KeyCode::Left, - modifiers: KeyModifiers::ALT, - .. - } - | KeyEvent { - code: KeyCode::Left, - modifiers: KeyModifiers::CONTROL, - .. - } => { - self.set_cursor(self.beginning_of_previous_word()); - } - KeyEvent { - code: KeyCode::Right, - modifiers: KeyModifiers::ALT, - .. - } - | KeyEvent { - code: KeyCode::Right, - modifiers: KeyModifiers::CONTROL, - .. - } => { - self.set_cursor(self.end_of_next_word()); - } - KeyEvent { - code: KeyCode::Up, .. - } => { - self.move_cursor_up(); - } - KeyEvent { - code: KeyCode::Down, - .. - } => { - self.move_cursor_down(); - } - KeyEvent { - code: KeyCode::Home, - .. - } => { - self.move_cursor_to_beginning_of_line(/*move_up_at_bol*/ false); - } - KeyEvent { - code: KeyCode::Char('a'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - self.move_cursor_to_beginning_of_line(/*move_up_at_bol*/ true); - } - - KeyEvent { - code: KeyCode::End, .. - } => { - self.move_cursor_to_end_of_line(/*move_down_at_eol*/ false); - } - KeyEvent { - code: KeyCode::Char('e'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - self.move_cursor_to_end_of_line(/*move_down_at_eol*/ true); - } - _ => {} + pub fn input_with_keymap(&mut self, event: KeyEvent, keymap: &EditorKeymap) { + if keymap.insert_newline.is_pressed(event) { + self.insert_str("\n"); + return; } + + if keymap.delete_backward_word.is_pressed(event) { + self.delete_backward_word(); + return; + } + + // Windows AltGr generates ALT|CONTROL. Preserve typed characters for AltGr users + // unless a specific shortcut already matched above. + if let KeyEvent { + code: KeyCode::Char(c), + modifiers, + .. + } = event + && is_altgr(modifiers) + { + self.insert_str(&c.to_string()); + return; + } + + if keymap.delete_backward.is_pressed(event) { + self.delete_backward(/*n*/ 1); + return; + } + if keymap.delete_forward_word.is_pressed(event) { + self.delete_forward_word(); + return; + } + if keymap.delete_forward.is_pressed(event) { + self.delete_forward(/*n*/ 1); + return; + } + if keymap.kill_line_start.is_pressed(event) { + self.kill_to_beginning_of_line(); + return; + } + if keymap.kill_line_end.is_pressed(event) { + self.kill_to_end_of_line(); + return; + } + if keymap.yank.is_pressed(event) { + self.yank(); + return; + } + if keymap.move_word_left.is_pressed(event) { + self.set_cursor(self.beginning_of_previous_word()); + return; + } + if keymap.move_word_right.is_pressed(event) { + self.set_cursor(self.end_of_next_word()); + return; + } + if keymap.move_left.is_pressed(event) { + self.move_cursor_left(); + return; + } + if keymap.move_right.is_pressed(event) { + self.move_cursor_right(); + return; + } + if keymap.move_up.is_pressed(event) { + self.move_cursor_up(); + return; + } + if keymap.move_down.is_pressed(event) { + self.move_cursor_down(); + return; + } + if keymap.move_line_start.is_pressed(event) { + let move_up_at_bol = matches!( + event, + KeyEvent { + code: KeyCode::Char('a'), + modifiers: KeyModifiers::CONTROL, + .. + } + ); + self.move_cursor_to_beginning_of_line(move_up_at_bol); + return; + } + if keymap.move_line_end.is_pressed(event) { + let move_down_at_eol = matches!( + event, + KeyEvent { + code: KeyCode::Char('e'), + modifiers: KeyModifiers::CONTROL, + .. + } + ); + self.move_cursor_to_end_of_line(move_down_at_eol); + return; + } + + if let KeyEvent { + code: KeyCode::Char(c), + modifiers: KeyModifiers::NONE | KeyModifiers::SHIFT, + .. + } = event + { + // Insert plain characters (and Shift-modified). Do not insert when ALT is held, + // because many terminals map Option/Meta combos to ALT+. + if c.is_ascii_control() { + return; + } + self.insert_str(&c.to_string()); + return; + } + + let _ = event; } // ####### Input Functions ####### @@ -1941,6 +1836,37 @@ mod tests { assert_eq!(t.cursor(), 2); } + #[test] + fn c0_control_chars_respect_unbound_editor_movement() { + let mut t = ta_with("a\nb"); + t.set_cursor(/*pos*/ 2); + let mut keymap = RuntimeKeymap::defaults().editor; + keymap.move_up.clear(); + + t.input_with_keymap( + KeyEvent::new(KeyCode::Char('\u{0010}'), KeyModifiers::NONE), + &keymap, + ); + + assert_eq!(t.cursor(), 2); + } + + #[test] + fn c0_control_chars_respect_remapped_editor_movement() { + let mut t = ta_with("a\nb"); + t.set_cursor(/*pos*/ 0); + let mut keymap = RuntimeKeymap::defaults().editor; + keymap.move_up.clear(); + keymap.move_down = vec![crate::key_hint::ctrl(KeyCode::Char('p'))]; + + t.input_with_keymap( + KeyEvent::new(KeyCode::Char('\u{0010}'), KeyModifiers::NONE), + &keymap, + ); + + assert_eq!(t.cursor(), 2); + } + #[test] fn delete_backward_word_alt_keys() { // Test the custom Alt+Ctrl+h binding diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 2b18718860..83cb972c74 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -311,6 +311,17 @@ fn queued_message_edit_binding_for_terminal(terminal_info: TerminalInfo) -> KeyB } } +fn queued_message_edit_hint_binding( + bindings: &[KeyBinding], + terminal_info: TerminalInfo, +) -> Option { + let terminal_binding = queued_message_edit_binding_for_terminal(terminal_info); + bindings + .contains(&terminal_binding) + .then_some(terminal_binding) + .or_else(|| bindings.first().copied()) +} + use crate::app_event::AppEvent; use crate::app_event::ConnectorsSnapshot; use crate::app_event::ExitMode; @@ -361,8 +372,9 @@ use crate::history_cell::PlainHistoryCell; use crate::history_cell::WebSearchCell; use crate::key_hint; use crate::key_hint::KeyBinding; -#[cfg(test)] -use crate::markdown::append_markdown; +use crate::key_hint::KeyBindingListExt; +use crate::keymap::ChatKeymap; +use crate::keymap::RuntimeKeymap; use crate::render::Insets; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::FlexRenderable; @@ -382,6 +394,7 @@ use self::goal_status::goal_status_indicator_from_app_goal; mod goal_menu; mod interrupts; use self::interrupts::InterruptManager; +mod keymap_picker; mod session_header; use self::session_header::SessionHeader; mod skills; @@ -832,6 +845,7 @@ pub(crate) struct ChatWidget { plan_stream_controller: Option, /// Holds the platform clipboard lease so copied text remains available while supported. clipboard_lease: Option, + copy_last_response_binding: Vec, /// Raw markdown of the most recently completed agent response that /// survived any local thread rollback. last_agent_markdown: Option, @@ -965,11 +979,12 @@ pub(crate) struct ChatWidget { // When set, the next interrupt should resubmit all pending steers as one // fresh user turn instead of restoring them into the composer. submit_pending_steers_after_interrupt: bool, - /// Terminal-appropriate keybinding for popping the most-recently queued - /// message back into the composer. Determined once at construction time via - /// [`queued_message_edit_binding_for_terminal`] and propagated to - /// `BottomPane` so the hint text matches the actual shortcut. - queued_message_edit_binding: KeyBinding, + /// Main chat-surface bindings resolved from `tui.keymap.chat`. + chat_keymap: ChatKeymap, + /// Keybinding to show for popping the most-recently queued message back + /// into the composer. This may differ from the first configured binding + /// when the default set includes a terminal-specific fallback. + queued_message_edit_hint_binding: Option, // Pending notification to show when unfocused on next Draw pending_notification: Option, /// When `Some`, the user has pressed a quit shortcut and the second press @@ -5482,7 +5497,21 @@ impl ChatWidget { let current_cwd = Some(config.cwd.to_path_buf()); let effective_service_tier = config.service_tier; - let queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info()); + let current_terminal_info = terminal_info(); + let runtime_keymap = RuntimeKeymap::from_config(&config.tui_keymap).ok(); + let default_keymap = RuntimeKeymap::defaults(); + let copy_last_response_binding = runtime_keymap + .as_ref() + .map(|keymap| keymap.app.copy.clone()) + .unwrap_or_else(|| default_keymap.app.copy.clone()); + let chat_keymap = runtime_keymap + .as_ref() + .map(|keymap| keymap.chat.clone()) + .unwrap_or_else(|| default_keymap.chat.clone()); + let queued_message_edit_hint_binding = queued_message_edit_hint_binding( + &chat_keymap.edit_queued_message, + current_terminal_info, + ); let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), @@ -5524,6 +5553,7 @@ impl ChatWidget { stream_controller: None, plan_stream_controller: None, clipboard_lease: None, + copy_last_response_binding, running_commands: HashMap::new(), collab_agent_metadata: HashMap::new(), pending_collab_spawn_requests: HashMap::new(), @@ -5583,7 +5613,8 @@ impl ChatWidget { rejected_steer_history_records: VecDeque::new(), pending_steers: VecDeque::new(), submit_pending_steers_after_interrupt: false, - queued_message_edit_binding, + chat_keymap, + queued_message_edit_hint_binding, show_welcome_banner: is_first_run, startup_tooltip_override, suppress_session_configured_redraw: false, @@ -5628,6 +5659,10 @@ impl ChatWidget { last_non_retry_error: None, }; + widget.prefetch_rate_limits(); + if let Some(keymap) = runtime_keymap { + widget.bottom_pane.set_keymap_bindings(&keymap); + } widget .bottom_pane .set_realtime_conversation_enabled(widget.realtime_conversation_enabled()); @@ -5646,7 +5681,7 @@ impl ChatWidget { widget.sync_goal_command_enabled(); widget .bottom_pane - .set_queued_message_edit_binding(widget.queued_message_edit_binding); + .set_queued_message_edit_binding(widget.queued_message_edit_hint_binding); #[cfg(target_os = "windows")] widget.bottom_pane.set_windows_degraded_sandbox_active( crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED @@ -5666,6 +5701,24 @@ impl ChatWidget { } pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) { + if self.bottom_pane.has_active_view() + && !matches!( + key_event, + KeyEvent { + code: KeyCode::Char(c), + modifiers, + kind: KeyEventKind::Press, + .. + } if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'c') + ) + { + self.bottom_pane.handle_key_event(key_event); + if self.bottom_pane.no_modal_or_popup_active() { + self.maybe_send_next_queued_input(); + } + return; + } + if self.handle_reasoning_shortcut(key_event) { self.bottom_pane.clear_quit_shortcut_hint(); self.quit_shortcut_expires_at = None; @@ -5673,20 +5726,17 @@ impl ChatWidget { return; } + if key_event.kind == KeyEventKind::Press + && self.copy_last_response_binding.is_pressed(key_event) + { + self.bottom_pane.clear_quit_shortcut_hint(); + self.quit_shortcut_expires_at = None; + self.quit_shortcut_key = None; + self.copy_last_agent_markdown(); + return; + } + match key_event { - // Ctrl+O - copy last agent response from the main view. - KeyEvent { - code: KeyCode::Char('o'), - modifiers: KeyModifiers::CONTROL, - kind: KeyEventKind::Press, - .. - } => { - self.bottom_pane.clear_quit_shortcut_hint(); - self.quit_shortcut_expires_at = None; - self.quit_shortcut_key = None; - self.copy_last_agent_markdown(); - return; - } KeyEvent { code: KeyCode::Char(c), modifiers, @@ -5745,8 +5795,9 @@ impl ChatWidget { } if key_event.kind == KeyEventKind::Press - && self.queued_message_edit_binding.is_press(key_event) + && self.chat_keymap.edit_queued_message.is_pressed(key_event) && self.has_queued_follow_up_messages() + && self.bottom_pane.no_modal_or_popup_active() { if let Some(user_message) = self.pop_latest_queued_user_message() { self.restore_user_message_to_composer(user_message); @@ -7873,7 +7924,7 @@ impl ChatWidget { } else { // Show explanation when there are no structured findings. let mut rendered: Vec> = vec!["".into()]; - append_markdown( + crate::markdown::append_markdown( &explanation, /*width*/ None, Some(self.config.cwd.as_path()), @@ -9075,7 +9126,7 @@ impl ChatWidget { "Access legacy models by running codex -m or in your config.toml", ); self.bottom_pane.show_selection_view(SelectionViewParams { - footer_hint: Some("Press enter to select reasoning effort, or esc to dismiss.".into()), + footer_hint: Some(self.bottom_pane.standard_popup_hint_line()), items, header, ..Default::default() @@ -11279,7 +11330,7 @@ impl ChatWidget { SelectionViewParams { view_id: Some(CONNECTORS_SELECTION_VIEW_ID), header: Box::new(header), - footer_hint: Some(Self::connectors_popup_hint_line()), + footer_hint: Some(self.bottom_pane.standard_popup_hint_line()), items, is_searchable: true, search_placeholder: Some("Type to search apps".to_string()), @@ -11309,14 +11360,6 @@ impl ChatWidget { ); } - fn connectors_popup_hint_line() -> Line<'static> { - Line::from(vec![ - "Press ".into(), - key_hint::plain(KeyCode::Esc).into(), - " to close.".into(), - ]) - } - fn connector_brief_description(connector: &AppInfo) -> String { let status_label = Self::connector_status_label(connector); match Self::connector_description(connector) { diff --git a/codex-rs/tui/src/chatwidget/keymap_picker.rs b/codex-rs/tui/src/chatwidget/keymap_picker.rs new file mode 100644 index 0000000000..bd4df0a362 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/keymap_picker.rs @@ -0,0 +1,165 @@ +//! `ChatWidget` integration points for the `/keymap` picker flow. +//! +//! The picker model, capture view, and edit semantics live in [`crate::keymap_setup`]. This module +//! keeps only the `ChatWidget`-owned responsibilities: opening those views in the bottom pane, +//! routing users back to the right picker row after an edit, and synchronizing the committed +//! keymap config back into the live widget state. Keeping these methods outside `chatwidget.rs` +//! keeps the main transcript/event surface from also owning the `/keymap` navigation details. +//! +//! The important invariant is that any accepted keymap edit must update three places together: +//! the stored `Config.tui_keymap`, the cached copy-response binding used by app-level shortcuts, +//! and the bottom pane's runtime keymap bindings. Updating only one of those would make the UI +//! appear to accept a remap while some handlers still respond to the old keys. + +use codex_config::types::TuiKeymap; +use codex_terminal_detection::terminal_info; + +use super::ChatWidget; +use super::queued_message_edit_hint_binding; +use crate::app_event::KeymapEditIntent; +use crate::keymap::RuntimeKeymap; +use crate::keymap_setup; + +impl ChatWidget { + /// Opens the root `/keymap` picker using the current `tui.keymap` configuration. + /// + /// This validates the persisted keymap before building picker rows because every subsequent + /// picker screen needs the effective runtime bindings, including preset defaults and user + /// overrides. If the config is invalid, the user sees the parse error instead of a partial + /// picker that could commit edits against stale runtime state. + pub(crate) fn open_keymap_picker(&mut self) { + match RuntimeKeymap::from_config(&self.config.tui_keymap) { + Ok(runtime_keymap) => { + let params = keymap_setup::build_keymap_picker_params( + &runtime_keymap, + &self.config.tui_keymap, + ); + self.bottom_pane.show_selection_view(params); + } + Err(err) => { + self.add_error_message(format!("Invalid `tui.keymap` configuration: {err}")); + } + } + } + + /// Opens the per-action menu for one keymap action. + /// + /// Callers pass the already-resolved runtime keymap from the app event that selected the + /// action. Recomputing it here would risk showing a menu for a different config if another + /// keymap edit was applied between the picker event and this handler. + pub(crate) fn open_keymap_action_menu( + &mut self, + context: String, + action: String, + runtime_keymap: &RuntimeKeymap, + ) { + let params = keymap_setup::build_keymap_action_menu_params( + context, + action, + runtime_keymap, + &self.config.tui_keymap, + ); + self.bottom_pane.show_selection_view(params); + } + + /// Opens the key-capture view for a set, replace, or alternate-binding edit. + /// + /// The capture view owns raw key interpretation, but `ChatWidget` supplies the event sender so + /// the captured key can come back through the same app-event path as menu selections. Bypassing + /// that path would skip config persistence and leave the runtime keymap cache unchanged. + pub(crate) fn open_keymap_capture( + &mut self, + context: String, + action: String, + intent: KeymapEditIntent, + runtime_keymap: &RuntimeKeymap, + ) { + let view = keymap_setup::build_keymap_capture_view( + context, + action, + intent, + runtime_keymap, + self.app_event_tx.clone(), + ); + self.bottom_pane.show_view(Box::new(view)); + self.request_redraw(); + } + + /// Opens the menu that lets the user choose which existing binding to replace. + /// + /// This is only used for actions with multiple effective bindings. The chosen binding is + /// carried through the subsequent capture intent so replacement edits do not accidentally + /// collapse alternate bindings that should remain available. + pub(crate) fn open_keymap_replace_binding_menu( + &mut self, + context: String, + action: String, + runtime_keymap: &RuntimeKeymap, + ) { + let params = + keymap_setup::build_keymap_replace_binding_menu_params(context, action, runtime_keymap); + self.bottom_pane.show_selection_view(params); + } + + /// Returns to the root picker with the edited action selected. + /// + /// The preferred path replaces any active keymap picker submenu in place so the bottom-pane + /// back stack does not accumulate obsolete menus after each edit. If the expected view stack is + /// no longer active, this falls back to showing a fresh picker rather than dropping the user on + /// a stale screen. + pub(crate) fn return_to_keymap_picker( + &mut self, + context: &str, + action: &str, + runtime_keymap: &RuntimeKeymap, + ) { + let params = keymap_setup::build_keymap_picker_params_for_selected_action( + runtime_keymap, + &self.config.tui_keymap, + context, + action, + ); + let replaced = self.bottom_pane.replace_active_views_with_selection_view( + &[ + keymap_setup::KEYMAP_PICKER_VIEW_ID, + keymap_setup::KEYMAP_ACTION_MENU_VIEW_ID, + keymap_setup::KEYMAP_REPLACE_BINDING_MENU_VIEW_ID, + ], + params, + ); + if !replaced { + let params = keymap_setup::build_keymap_picker_params_for_selected_action( + runtime_keymap, + &self.config.tui_keymap, + context, + action, + ); + self.bottom_pane.show_selection_view(params); + } + self.request_redraw(); + } + + /// Applies a committed keymap edit to the live chat widget. + /// + /// The caller is responsible for persisting the config file before invoking this method. This + /// method updates the in-memory config, app-level copy binding cache, and bottom-pane keymap + /// bindings as one unit; callers that update only `self.config.tui_keymap` would leave visible + /// picker state and active key handlers disagreeing until the next restart. + pub(crate) fn apply_keymap_update( + &mut self, + keymap_config: TuiKeymap, + runtime_keymap: &RuntimeKeymap, + ) { + self.config.tui_keymap = keymap_config; + self.copy_last_response_binding = runtime_keymap.app.copy.clone(); + self.chat_keymap = runtime_keymap.chat.clone(); + self.queued_message_edit_hint_binding = queued_message_edit_hint_binding( + &self.chat_keymap.edit_queued_message, + terminal_info(), + ); + self.bottom_pane + .set_queued_message_edit_binding(self.queued_message_edit_hint_binding); + self.bottom_pane.set_keymap_bindings(runtime_keymap); + self.request_redraw(); + } +} diff --git a/codex-rs/tui/src/chatwidget/reasoning_shortcuts.rs b/codex-rs/tui/src/chatwidget/reasoning_shortcuts.rs index ef37caaa89..612dbe7ddd 100644 --- a/codex-rs/tui/src/chatwidget/reasoning_shortcuts.rs +++ b/codex-rs/tui/src/chatwidget/reasoning_shortcuts.rs @@ -15,14 +15,12 @@ use codex_protocol::config_types::ModeKind; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; -use crossterm::event::KeyCode; use crossterm::event::KeyEvent; -use crossterm::event::KeyEventKind; -use crossterm::event::KeyModifiers; use strum::IntoEnumIterator; use super::ChatWidget; use crate::app_event::AppEvent; +use crate::key_hint::KeyBindingListExt; /// Direction requested by a reasoning-level shortcut. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -32,18 +30,6 @@ pub(super) enum ReasoningShortcutDirection { } impl ReasoningShortcutDirection { - fn from_key_event(key_event: KeyEvent) -> Option { - if key_event.kind != KeyEventKind::Press || key_event.modifiers != KeyModifiers::ALT { - return None; - } - - match key_event.code { - KeyCode::Char(',') => Some(Self::Lower), - KeyCode::Char('.') => Some(Self::Raise), - _ => None, - } - } - fn bound_message(self, effort: ReasoningEffortConfig) -> String { let label = ChatWidget::reasoning_effort_label(effort).to_lowercase(); match self { @@ -66,7 +52,19 @@ impl ChatWidget { /// persisting them. In Plan mode, shortcuts apply only to the active /// Plan-mode override and skip the global-vs-Plan scope prompt. pub(super) fn handle_reasoning_shortcut(&mut self, key_event: KeyEvent) -> bool { - let Some(direction) = ReasoningShortcutDirection::from_key_event(key_event) else { + let direction = if self + .chat_keymap + .decrease_reasoning_effort + .is_pressed(key_event) + { + ReasoningShortcutDirection::Lower + } else if self + .chat_keymap + .increase_reasoning_effort + .is_pressed(key_event) + { + ReasoningShortcutDirection::Raise + } else { return false; }; diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 978e19dd6f..4936f385be 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -243,6 +243,9 @@ impl ChatWidget { SlashCommand::Permissions => { self.open_permissions_popup(); } + SlashCommand::Keymap => { + self.open_keymap_picker(); + } SlashCommand::ElevateSandbox => { #[cfg(target_os = "windows")] { @@ -860,6 +863,7 @@ impl ChatWidget { | SlashCommand::Goal | SlashCommand::Collab | SlashCommand::Side + | SlashCommand::Keymap | SlashCommand::Agent | SlashCommand::MultiAgents | SlashCommand::Approvals diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__apps_popup_loading_state.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__apps_popup_loading_state.snap index 94d7aab6da..a13cb7a009 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__apps_popup_loading_state.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__apps_popup_loading_state.snap @@ -1,8 +1,10 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/popups_and_settings.rs expression: before --- Apps Loading installed and available apps... › Loading apps... This updates when the full list is ready. + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_selection_popup.snap index 1b7627a97e..2cd2fb4fe3 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_selection_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_selection_popup.snap @@ -1,5 +1,5 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/popups_and_settings.rs expression: popup --- How was this? @@ -11,3 +11,5 @@ expression: popup 4. safety check Benign usage blocked due to safety checks or refusals. 5. other Slowness, feature suggestion, UX feedback, or anything else. + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_picker_filters_hidden_models.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_picker_filters_hidden_models.snap index 3deb2f9656..f3ab226535 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_picker_filters_hidden_models.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_picker_filters_hidden_models.snap @@ -1,5 +1,5 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/popups_and_settings.rs expression: popup --- Select Model and Effort @@ -7,4 +7,4 @@ expression: popup › 1. test-visible-model (current) test-visible-model description - Press enter to select reasoning effort, or esc to dismiss. + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap index 6425910778..7c1d51d599 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap @@ -14,4 +14,4 @@ expression: popup › 5. gpt-5.2 (current) Optimized for professional work and long-running agents. - Press enter to select reasoning effort, or esc to dismiss. + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_loading_state.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_loading_state.snap index 27fbf8f5f7..ff09ca3343 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_loading_state.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_loading_state.snap @@ -1,5 +1,5 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/popups_and_settings.rs expression: popup --- Plugins @@ -7,3 +7,5 @@ expression: popup This updates when the marketplace list is ready. › Loading plugins... This updates when the marketplace list is ready. + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index d21febaa81..a99bc15183 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -937,9 +937,10 @@ async fn restore_thread_input_state_syncs_sleep_inhibitor_state() { #[tokio::test] async fn alt_up_edits_most_recent_queued_message() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.queued_message_edit_binding = crate::key_hint::alt(KeyCode::Up); + chat.chat_keymap.edit_queued_message = vec![crate::key_hint::alt(KeyCode::Up)]; + chat.queued_message_edit_hint_binding = Some(crate::key_hint::alt(KeyCode::Up)); chat.bottom_pane - .set_queued_message_edit_binding(crate::key_hint::alt(KeyCode::Up)); + .set_queued_message_edit_binding(chat.queued_message_edit_hint_binding); // Simulate a running task so messages would normally be queued. chat.bottom_pane.set_task_running(/*running*/ true); @@ -967,6 +968,24 @@ async fn alt_up_edits_most_recent_queued_message() { ); } +#[tokio::test] +async fn unbound_queued_message_edit_does_not_fall_back_to_alt_up() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.chat_keymap.edit_queued_message = Vec::new(); + chat.queued_message_edit_hint_binding = None; + chat.bottom_pane + .set_queued_message_edit_binding(chat.queued_message_edit_hint_binding); + chat.bottom_pane.set_task_running(/*running*/ true); + chat.queued_user_messages + .push_back(UserMessage::from("queued".to_string()).into()); + chat.refresh_pending_input_preview(); + + chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT)); + + assert!(chat.bottom_pane.composer_text().is_empty()); + assert_eq!(chat.queued_user_messages.len(), 1); +} + #[tokio::test] async fn shift_left_edits_most_recent_queued_message_in_apple_terminal() { assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalInfo { diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 901a532f62..d68a03e8b5 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -211,6 +211,7 @@ pub(super) async fn make_chatwidget_manual( stream_controller: None, plan_stream_controller: None, clipboard_lease: None, + copy_last_response_binding: crate::keymap::RuntimeKeymap::defaults().app.copy, pending_guardian_review_status: PendingGuardianReviewStatus::default(), recent_auto_review_denials: RecentAutoReviewDenials::default(), terminal_title_status_kind: TerminalTitleStatusKind::Working, @@ -275,7 +276,8 @@ pub(super) async fn make_chatwidget_manual( rejected_steer_history_records: VecDeque::new(), pending_steers: VecDeque::new(), submit_pending_steers_after_interrupt: false, - queued_message_edit_binding: crate::key_hint::alt(KeyCode::Up), + chat_keymap: crate::keymap::RuntimeKeymap::defaults().chat, + queued_message_edit_hint_binding: Some(crate::key_hint::alt(KeyCode::Up)), suppress_session_configured_redraw: false, suppress_initial_user_message_submit: false, pending_notification: None, @@ -739,9 +741,10 @@ pub(super) async fn assert_shift_left_edits_most_recent_queued_message_for_termi terminal_info: TerminalInfo, ) { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info); + chat.queued_message_edit_hint_binding = + Some(queued_message_edit_binding_for_terminal(terminal_info)); chat.bottom_pane - .set_queued_message_edit_binding(chat.queued_message_edit_binding); + .set_queued_message_edit_binding(chat.queued_message_edit_hint_binding); // Simulate a running task so messages would normally be queued. chat.bottom_pane.set_task_running(/*running*/ true); diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index f88846be94..a0ba30e645 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -1341,6 +1341,65 @@ async fn ctrl_o_copy_reports_when_no_agent_response_exists() { ); } +#[tokio::test] +async fn keymap_capture_can_capture_current_copy_shortcut() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let runtime_keymap = crate::keymap::RuntimeKeymap::defaults(); + chat.open_keymap_capture( + "composer".to_string(), + "submit".to_string(), + crate::app_event::KeymapEditIntent::ReplaceAll, + &runtime_keymap, + ); + + chat.handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL)); + + let AppEvent::KeymapCaptured { + context, + action, + key, + intent, + } = rx.try_recv().expect("captured key event") + else { + panic!("expected keymap capture event"); + }; + assert_eq!(context, "composer"); + assert_eq!(action, "submit"); + assert_eq!(key, "ctrl-o"); + assert_eq!(intent, crate::app_event::KeymapEditIntent::ReplaceAll); + assert!( + drain_insert_history(&mut rx).is_empty(), + "copy shortcut should not run while key capture is active" + ); +} + +#[tokio::test] +async fn copy_shortcut_can_be_remapped() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let mut keymap_config = chat.config_ref().tui_keymap.clone(); + keymap_config.global.copy = Some(codex_config::types::KeybindingsSpec::One( + codex_config::types::KeybindingSpec("ctrl-x".to_string()), + )); + let runtime_keymap = + crate::keymap::RuntimeKeymap::from_config(&keymap_config).expect("valid copy remap"); + chat.apply_keymap_update(keymap_config, &runtime_keymap); + + chat.handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL)); + assert!( + drain_insert_history(&mut rx).is_empty(), + "old copy shortcut should no longer copy" + ); + + chat.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)); + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected one info message"); + let rendered = lines_to_single_string(&cells[0]); + assert!( + rendered.contains("No agent response to copy"), + "expected remapped copy shortcut to run, got {rendered:?}" + ); +} + #[tokio::test] async fn slash_copy_stores_clipboard_lease_and_preserves_it_on_failure() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/key_hint.rs b/codex-rs/tui/src/key_hint.rs index f277f07384..9b24d9fbf0 100644 --- a/codex-rs/tui/src/key_hint.rs +++ b/codex-rs/tui/src/key_hint.rs @@ -15,7 +15,13 @@ const ALT_PREFIX: &str = "alt + "; const CTRL_PREFIX: &str = "ctrl + "; const SHIFT_PREFIX: &str = "shift + "; -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +/// One concrete key event that can trigger a TUI action. +/// +/// The binding stores the terminal key code plus the exact modifier set that +/// must be present on an incoming press or repeat event. It does not model +/// multi-key chords or partial matches; callers that need sequences must keep +/// that state outside this type. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub(crate) struct KeyBinding { key: KeyCode, modifiers: KeyModifiers, @@ -27,10 +33,61 @@ impl KeyBinding { } pub fn is_press(&self, event: KeyEvent) -> bool { - self.key == event.code - && self.modifiers == event.modifiers + normalize_shifted_ascii_char(self.key, self.modifiers) + == normalize_shifted_ascii_char(event.code, event.modifiers) && (event.kind == KeyEventKind::Press || event.kind == KeyEventKind::Repeat) } + + pub(crate) const fn parts(&self) -> (KeyCode, KeyModifiers) { + (self.key, self.modifiers) + } +} + +fn normalize_shifted_ascii_char( + key: KeyCode, + mut modifiers: KeyModifiers, +) -> (KeyCode, KeyModifiers) { + let KeyCode::Char(ch) = key else { + return (key, modifiers); + }; + if modifiers.is_empty() + && let Some(ctrl_char) = c0_control_char_to_ctrl_char(ch) + { + return (KeyCode::Char(ctrl_char), KeyModifiers::CONTROL | modifiers); + } + if ch.is_ascii_uppercase() { + modifiers.insert(KeyModifiers::SHIFT); + return (KeyCode::Char(ch.to_ascii_lowercase()), modifiers); + } + (key, modifiers) +} + +fn c0_control_char_to_ctrl_char(ch: char) -> Option { + match ch { + '\u{0002}' => Some('b'), + '\u{0006}' => Some('f'), + '\u{000e}' => Some('n'), + '\u{0010}' => Some('p'), + '\u{0012}' => Some('r'), + '\u{0013}' => Some('s'), + _ => None, + } +} + +/// Matching helpers for one action's keybinding set. +/// +/// Implementations are expected to treat the slice as alternatives for one +/// action. They should not interpret order as priority for dispatch; order is +/// reserved for UI hint selection via `primary_binding`. +pub(crate) trait KeyBindingListExt { + /// True when any binding in this set matches `event`. + fn is_pressed(&self, event: KeyEvent) -> bool; +} + +impl KeyBindingListExt for [KeyBinding] { + fn is_pressed(&self, event: KeyEvent) -> bool { + self.iter().any(|binding| binding.is_press(event)) + } } pub(crate) const fn plain(key: KeyCode) -> KeyBinding { @@ -110,3 +167,88 @@ pub(crate) fn is_altgr(mods: KeyModifiers) -> bool { pub(crate) fn is_altgr(_mods: KeyModifiers) -> bool { false } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_press_accepts_press_and_repeat_but_rejects_release() { + let binding = ctrl(KeyCode::Char('k')); + let press = KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL); + let repeat = KeyEvent { + kind: KeyEventKind::Repeat, + ..press + }; + let release = KeyEvent { + kind: KeyEventKind::Release, + ..press + }; + let wrong_modifiers = KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE); + + assert!(binding.is_press(press)); + assert!(binding.is_press(repeat)); + assert!(!binding.is_press(release)); + assert!(!binding.is_press(wrong_modifiers)); + } + + #[test] + fn keybinding_list_ext_matches_any_binding() { + let bindings = [plain(KeyCode::Char('a')), ctrl(KeyCode::Char('b'))]; + + assert!(bindings.is_pressed(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE))); + assert!(bindings.is_pressed(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL))); + assert!(!bindings.is_pressed(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE))); + } + + #[test] + fn shifted_letter_binding_matches_uppercase_char_events() { + let binding = shift(KeyCode::Char('a')); + + assert!(binding.is_press(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::NONE))); + assert!(binding.is_press(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT))); + } + + #[test] + fn ctrl_letter_binding_matches_c0_control_char_events() { + let binding = ctrl(KeyCode::Char('p')); + + assert!(binding.is_press(KeyEvent::new(KeyCode::Char('\u{0010}'), KeyModifiers::NONE))); + assert!(!binding.is_press(KeyEvent::new(KeyCode::Char('\u{0010}'), KeyModifiers::ALT))); + } + + #[test] + fn history_search_ctrl_bindings_match_c0_control_char_events() { + assert!( + ctrl(KeyCode::Char('r')) + .is_press(KeyEvent::new(KeyCode::Char('\u{0012}'), KeyModifiers::NONE)) + ); + assert!( + ctrl(KeyCode::Char('s')) + .is_press(KeyEvent::new(KeyCode::Char('\u{0013}'), KeyModifiers::NONE)) + ); + } + + #[test] + fn ctrl_alt_sets_both_modifiers() { + assert_eq!( + ctrl_alt(KeyCode::Char('v')).parts(), + ( + KeyCode::Char('v'), + KeyModifiers::CONTROL | KeyModifiers::ALT + ) + ); + } + + #[test] + fn has_ctrl_or_alt_checks_supported_modifier_combinations() { + assert!(!has_ctrl_or_alt(KeyModifiers::NONE)); + assert!(has_ctrl_or_alt(KeyModifiers::CONTROL)); + assert!(has_ctrl_or_alt(KeyModifiers::ALT)); + + #[cfg(windows)] + assert!(!has_ctrl_or_alt(KeyModifiers::CONTROL | KeyModifiers::ALT)); + #[cfg(not(windows))] + assert!(has_ctrl_or_alt(KeyModifiers::CONTROL | KeyModifiers::ALT)); + } +} diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs new file mode 100644 index 0000000000..70d2e99571 --- /dev/null +++ b/codex-rs/tui/src/keymap.rs @@ -0,0 +1,1530 @@ +//! Runtime keymap resolution for the TUI. +//! +//! This module converts deserialized config (`TuiKeymap`) into a concrete +//! `RuntimeKeymap` used by input handlers at runtime. +//! +//! Key responsibilities: +//! +//! 1. Apply deterministic precedence (`context -> global fallback -> defaults`). +//! 2. Parse canonical key spec strings into `KeyBinding` values. +//! 3. Enforce uniqueness across runtime surfaces so one key cannot trigger +//! multiple actions on the same focused input path. +//! 4. Return actionable, user-facing error messages with config paths and next +//! steps. +//! +//! Non-responsibilities: +//! +//! 1. This module does not decide which action should run in a given screen. +//! Callers resolve actions by checking the relevant action binding set. +//! 2. This module does not persist configuration; it only resolves loaded config. + +use crate::key_hint; +use crate::key_hint::KeyBinding; +use codex_config::types::KeybindingsSpec; +use codex_config::types::TuiKeymap; +use crossterm::event::KeyCode; +use crossterm::event::KeyModifiers; +use std::collections::HashMap; + +/// Runtime keymap used by TUI input handlers. +/// +/// Resolution precedence is: +/// +/// 1. Context-specific binding (`tui.keymap.`). +/// 2. `tui.keymap.global` for actions that support global fallback. +/// 3. Built-in defaults. +/// +/// This is the only shape UI code should use for dispatch. It represents a +/// fully resolved snapshot with parsing, fallback, explicit unbinding, and +/// duplicate-key validation already applied. If a caller keeps using an older +/// snapshot after config changes, visible hints and active handlers can drift. +#[derive(Clone, Debug)] +pub(crate) struct RuntimeKeymap { + pub(crate) app: AppKeymap, + pub(crate) chat: ChatKeymap, + pub(crate) composer: ComposerKeymap, + pub(crate) editor: EditorKeymap, + pub(crate) pager: PagerKeymap, + pub(crate) list: ListKeymap, + pub(crate) approval: ApprovalKeymap, +} + +#[derive(Clone, Debug)] +pub(crate) struct AppKeymap { + /// Open transcript overlay. + pub(crate) open_transcript: Vec, + /// Open external editor for the current draft. + pub(crate) open_external_editor: Vec, + /// Copy the last agent response to the clipboard. + pub(crate) copy: Vec, + /// Clear the terminal UI. + pub(crate) clear_terminal: Vec, +} + +/// Main chat-surface keybindings. +#[derive(Clone, Debug)] +pub(crate) struct ChatKeymap { + /// Decrease the active reasoning effort. + pub(crate) decrease_reasoning_effort: Vec, + /// Increase the active reasoning effort. + pub(crate) increase_reasoning_effort: Vec, + /// Edit the most recently queued message. + pub(crate) edit_queued_message: Vec, +} + +#[derive(Clone, Debug)] +pub(crate) struct ComposerKeymap { + /// Submit current draft. + pub(crate) submit: Vec, + /// Queue current draft while a task is running. + pub(crate) queue: Vec, + /// Toggle composer shortcut overlay. + pub(crate) toggle_shortcuts: Vec, + /// Open reverse history search or move to the previous match. + pub(crate) history_search_previous: Vec, + /// Move to the next match in reverse history search. + pub(crate) history_search_next: Vec, +} + +/// Editor-specific keybindings used by the composer textarea. +/// +/// These bindings are interpreted only by text-editing widgets and do not +/// participate in global/chat fallback resolution. +#[derive(Clone, Debug)] +pub(crate) struct EditorKeymap { + pub(crate) insert_newline: Vec, + pub(crate) move_left: Vec, + pub(crate) move_right: Vec, + pub(crate) move_up: Vec, + pub(crate) move_down: Vec, + pub(crate) move_word_left: Vec, + pub(crate) move_word_right: Vec, + pub(crate) move_line_start: Vec, + pub(crate) move_line_end: Vec, + pub(crate) delete_backward: Vec, + pub(crate) delete_forward: Vec, + pub(crate) delete_backward_word: Vec, + pub(crate) delete_forward_word: Vec, + pub(crate) kill_line_start: Vec, + pub(crate) kill_line_end: Vec, + pub(crate) yank: Vec, +} + +/// Pager/overlay keybindings for transcript and static help views. +#[derive(Clone, Debug)] +pub(crate) struct PagerKeymap { + pub(crate) scroll_up: Vec, + pub(crate) scroll_down: Vec, + pub(crate) page_up: Vec, + pub(crate) page_down: Vec, + pub(crate) half_page_up: Vec, + pub(crate) half_page_down: Vec, + pub(crate) jump_top: Vec, + pub(crate) jump_bottom: Vec, + pub(crate) close: Vec, + pub(crate) close_transcript: Vec, +} + +/// Generic list picker keybindings shared across popup list views. +#[derive(Clone, Debug)] +pub(crate) struct ListKeymap { + pub(crate) move_up: Vec, + pub(crate) move_down: Vec, + pub(crate) accept: Vec, + pub(crate) cancel: Vec, +} + +/// Approval modal keybindings. +/// +/// This covers both selection actions and the "open details fullscreen" escape +/// hatch for large approval payloads. +#[derive(Clone, Debug)] +pub(crate) struct ApprovalKeymap { + pub(crate) open_fullscreen: Vec, + pub(crate) open_thread: Vec, + pub(crate) approve: Vec, + pub(crate) approve_for_session: Vec, + pub(crate) approve_for_prefix: Vec, + pub(crate) deny: Vec, + pub(crate) decline: Vec, + pub(crate) cancel: Vec, +} + +/// Returns the first binding, used as the primary UI hint for an action. +/// +/// Rendering code should prefer this for concise hints while preserving all +/// bindings for actual input matching. +pub(crate) fn primary_binding(bindings: &[KeyBinding]) -> Option { + bindings.first().copied() +} + +/// Resolve one context-local action binding from config. +/// +/// Expands to `resolve_bindings(...)` with: +/// - configured source: `tui.keymap..` +/// - fallback source: the same action from built-in defaults +/// - error path: a stable string path for user-facing diagnostics +/// +/// This keeps the resolution table concise while guaranteeing path strings +/// stay in sync with field names. +macro_rules! resolve_local { + ($keymap:expr, $defaults:expr, $context:ident, $action:ident) => { + resolve_bindings( + ($keymap).$context.$action.as_ref(), + &($defaults).$context.$action, + concat!( + "tui.keymap.", + stringify!($context), + ".", + stringify!($action) + ), + )? + }; +} + +/// Resolve one action binding with global fallback. +/// +/// Expands to `resolve_bindings_with_global_fallback(...)` with precedence: +/// 1. `tui.keymap..` +/// 2. `tui.keymap.global.` +/// 3. built-in defaults for `.` +/// +/// Used only for actions that intentionally support global reuse. +/// Context-local empty lists still count as configured values, so they unbind +/// the action instead of falling back to `global`. +macro_rules! resolve_with_global { + ($keymap:expr, $defaults:expr, $context:ident, $action:ident) => { + resolve_bindings_with_global_fallback( + ($keymap).$context.$action.as_ref(), + ($keymap).global.$action.as_ref(), + &($defaults).$context.$action, + concat!( + "tui.keymap.", + stringify!($context), + ".", + stringify!($action) + ), + )? + }; +} + +/// Expand one default-table binding entry into a [`KeyBinding`]. +/// +/// This is a small declarative layer over `key_hint::{plain, ctrl, alt, shift}` +/// used by `default_bindings!` so `built_in_defaults` stays readable. +/// +/// Supported forms: +/// - `plain()` +/// - `ctrl()` +/// - `alt()` +/// - `shift()` +/// - `raw()` for bindings that do not match the helpers +/// (for example combined modifiers like Ctrl+Shift). +macro_rules! default_binding { + (plain($key:expr)) => { + key_hint::plain($key) + }; + (ctrl($key:expr)) => { + key_hint::ctrl($key) + }; + (alt($key:expr)) => { + key_hint::alt($key) + }; + (shift($key:expr)) => { + key_hint::shift($key) + }; + (raw($binding:expr)) => { + $binding + }; +} + +/// Build a `Vec` for built-in defaults. +/// +/// This macro is intentionally scoped to built-in keymaps. Runtime +/// config parsing still goes through `parse_bindings(...)` so user errors can +/// be reported with config-path-aware diagnostics. +macro_rules! default_bindings { + ($($kind:ident($($arg:tt)*)),* $(,)?) => { + vec![$(default_binding!($kind($($arg)*))),*] + }; +} + +impl RuntimeKeymap { + /// Return built-in defaults. + /// + /// This is a convenience for tests and bootstrapping UI state before user + /// config has been loaded. It should not be used as a fallback after + /// parsing `TuiKeymap`, because doing so would ignore explicit user + /// unbindings and conflict diagnostics. + pub(crate) fn defaults() -> Self { + Self::built_in_defaults() + } + + /// Resolve a runtime keymap from config, applying precedence and validation. + /// + /// Returns an error when: + /// + /// 1. A keybinding spec cannot be parsed. + /// 2. A context has ambiguous bindings (same key assigned to multiple actions). + /// + /// The error text includes the relevant config path and a concrete next step. + /// Calling code should not merge bindings across unrelated contexts before + /// dispatch, or conflict guarantees from this resolver no longer hold. + pub(crate) fn from_config(keymap: &TuiKeymap) -> Result { + let defaults = Self::built_in_defaults(); + + let app = AppKeymap { + open_transcript: resolve_bindings( + keymap.global.open_transcript.as_ref(), + &defaults.app.open_transcript, + "tui.keymap.global.open_transcript", + )?, + open_external_editor: resolve_bindings( + keymap.global.open_external_editor.as_ref(), + &defaults.app.open_external_editor, + "tui.keymap.global.open_external_editor", + )?, + copy: resolve_bindings( + keymap.global.copy.as_ref(), + &defaults.app.copy, + "tui.keymap.global.copy", + )?, + clear_terminal: resolve_bindings( + keymap.global.clear_terminal.as_ref(), + &defaults.app.clear_terminal, + "tui.keymap.global.clear_terminal", + )?, + }; + + let chat = ChatKeymap { + decrease_reasoning_effort: resolve_bindings( + keymap.chat.decrease_reasoning_effort.as_ref(), + &defaults.chat.decrease_reasoning_effort, + "tui.keymap.chat.decrease_reasoning_effort", + )?, + increase_reasoning_effort: resolve_bindings( + keymap.chat.increase_reasoning_effort.as_ref(), + &defaults.chat.increase_reasoning_effort, + "tui.keymap.chat.increase_reasoning_effort", + )?, + edit_queued_message: resolve_bindings( + keymap.chat.edit_queued_message.as_ref(), + &defaults.chat.edit_queued_message, + "tui.keymap.chat.edit_queued_message", + )?, + }; + + let composer = ComposerKeymap { + submit: resolve_with_global!(keymap, defaults, composer, submit), + queue: resolve_with_global!(keymap, defaults, composer, queue), + toggle_shortcuts: resolve_with_global!(keymap, defaults, composer, toggle_shortcuts), + history_search_previous: resolve_local!( + keymap, + defaults, + composer, + history_search_previous + ), + history_search_next: resolve_local!(keymap, defaults, composer, history_search_next), + }; + + let editor = EditorKeymap { + insert_newline: resolve_local!(keymap, defaults, editor, insert_newline), + move_left: resolve_local!(keymap, defaults, editor, move_left), + move_right: resolve_local!(keymap, defaults, editor, move_right), + move_up: resolve_local!(keymap, defaults, editor, move_up), + move_down: resolve_local!(keymap, defaults, editor, move_down), + move_word_left: resolve_local!(keymap, defaults, editor, move_word_left), + move_word_right: resolve_local!(keymap, defaults, editor, move_word_right), + move_line_start: resolve_local!(keymap, defaults, editor, move_line_start), + move_line_end: resolve_local!(keymap, defaults, editor, move_line_end), + delete_backward: resolve_local!(keymap, defaults, editor, delete_backward), + delete_forward: resolve_local!(keymap, defaults, editor, delete_forward), + delete_backward_word: resolve_local!(keymap, defaults, editor, delete_backward_word), + delete_forward_word: resolve_local!(keymap, defaults, editor, delete_forward_word), + kill_line_start: resolve_local!(keymap, defaults, editor, kill_line_start), + kill_line_end: resolve_local!(keymap, defaults, editor, kill_line_end), + yank: resolve_local!(keymap, defaults, editor, yank), + }; + + let pager = PagerKeymap { + scroll_up: resolve_local!(keymap, defaults, pager, scroll_up), + scroll_down: resolve_local!(keymap, defaults, pager, scroll_down), + page_up: resolve_local!(keymap, defaults, pager, page_up), + page_down: resolve_local!(keymap, defaults, pager, page_down), + half_page_up: resolve_local!(keymap, defaults, pager, half_page_up), + half_page_down: resolve_local!(keymap, defaults, pager, half_page_down), + jump_top: resolve_local!(keymap, defaults, pager, jump_top), + jump_bottom: resolve_local!(keymap, defaults, pager, jump_bottom), + close: resolve_local!(keymap, defaults, pager, close), + close_transcript: resolve_local!(keymap, defaults, pager, close_transcript), + }; + + let list = ListKeymap { + move_up: resolve_local!(keymap, defaults, list, move_up), + move_down: resolve_local!(keymap, defaults, list, move_down), + accept: resolve_local!(keymap, defaults, list, accept), + cancel: resolve_local!(keymap, defaults, list, cancel), + }; + + let approval = ApprovalKeymap { + open_fullscreen: resolve_local!(keymap, defaults, approval, open_fullscreen), + open_thread: resolve_local!(keymap, defaults, approval, open_thread), + approve: resolve_local!(keymap, defaults, approval, approve), + approve_for_session: resolve_local!(keymap, defaults, approval, approve_for_session), + approve_for_prefix: resolve_local!(keymap, defaults, approval, approve_for_prefix), + deny: resolve_local!(keymap, defaults, approval, deny), + decline: resolve_local!(keymap, defaults, approval, decline), + cancel: resolve_local!(keymap, defaults, approval, cancel), + }; + + let resolved = Self { + app, + chat, + composer, + editor, + pager, + list, + approval, + }; + + resolved.validate_conflicts()?; + Ok(resolved) + } + + /// Built-in keymap defaults. + /// + /// Some actions intentionally include compatibility variants (for example + /// both `?` and `shift-?`) because terminals disagree on whether SHIFT is + /// preserved for certain printable/control chords. + fn built_in_defaults() -> Self { + Self { + app: AppKeymap { + open_transcript: default_bindings![ctrl(KeyCode::Char('t'))], + open_external_editor: default_bindings![ctrl(KeyCode::Char('g'))], + copy: default_bindings![ctrl(KeyCode::Char('o'))], + clear_terminal: default_bindings![ctrl(KeyCode::Char('l'))], + }, + chat: ChatKeymap { + decrease_reasoning_effort: default_bindings![alt(KeyCode::Char(','))], + increase_reasoning_effort: default_bindings![alt(KeyCode::Char('.'))], + edit_queued_message: default_bindings![alt(KeyCode::Up), shift(KeyCode::Left)], + }, + composer: ComposerKeymap { + submit: default_bindings![plain(KeyCode::Enter)], + queue: default_bindings![plain(KeyCode::Tab)], + toggle_shortcuts: default_bindings![ + plain(KeyCode::Char('?')), + shift(KeyCode::Char('?')) + ], + history_search_previous: default_bindings![ctrl(KeyCode::Char('r'))], + history_search_next: default_bindings![ctrl(KeyCode::Char('s'))], + }, + editor: EditorKeymap { + insert_newline: default_bindings![ + ctrl(KeyCode::Char('j')), + ctrl(KeyCode::Char('m')), + plain(KeyCode::Enter), + shift(KeyCode::Enter) + ], + move_left: default_bindings![plain(KeyCode::Left), ctrl(KeyCode::Char('b'))], + move_right: default_bindings![plain(KeyCode::Right), ctrl(KeyCode::Char('f'))], + move_up: default_bindings![plain(KeyCode::Up), ctrl(KeyCode::Char('p'))], + move_down: default_bindings![plain(KeyCode::Down), ctrl(KeyCode::Char('n'))], + move_word_left: default_bindings![ + alt(KeyCode::Char('b')), + raw(KeyBinding::new(KeyCode::Left, KeyModifiers::ALT)), + raw(KeyBinding::new(KeyCode::Left, KeyModifiers::CONTROL)) + ], + move_word_right: default_bindings![ + alt(KeyCode::Char('f')), + raw(KeyBinding::new(KeyCode::Right, KeyModifiers::ALT)), + raw(KeyBinding::new(KeyCode::Right, KeyModifiers::CONTROL)) + ], + move_line_start: default_bindings![plain(KeyCode::Home), ctrl(KeyCode::Char('a'))], + move_line_end: default_bindings![plain(KeyCode::End), ctrl(KeyCode::Char('e'))], + delete_backward: default_bindings![ + plain(KeyCode::Backspace), + ctrl(KeyCode::Char('h')) + ], + delete_forward: default_bindings![plain(KeyCode::Delete), ctrl(KeyCode::Char('d'))], + delete_backward_word: default_bindings![ + alt(KeyCode::Backspace), + ctrl(KeyCode::Char('w')), + raw(KeyBinding::new( + KeyCode::Char('h'), + KeyModifiers::CONTROL | KeyModifiers::ALT, + )) + ], + delete_forward_word: default_bindings![ + alt(KeyCode::Delete), + alt(KeyCode::Char('d')) + ], + kill_line_start: default_bindings![ctrl(KeyCode::Char('u'))], + kill_line_end: default_bindings![ctrl(KeyCode::Char('k'))], + yank: default_bindings![ctrl(KeyCode::Char('y'))], + }, + pager: PagerKeymap { + scroll_up: default_bindings![plain(KeyCode::Up), plain(KeyCode::Char('k'))], + scroll_down: default_bindings![plain(KeyCode::Down), plain(KeyCode::Char('j'))], + page_up: default_bindings![ + plain(KeyCode::PageUp), + shift(KeyCode::Char(' ')), + ctrl(KeyCode::Char('b')) + ], + page_down: default_bindings![ + plain(KeyCode::PageDown), + plain(KeyCode::Char(' ')), + ctrl(KeyCode::Char('f')) + ], + half_page_up: default_bindings![ctrl(KeyCode::Char('u'))], + half_page_down: default_bindings![ctrl(KeyCode::Char('d'))], + jump_top: default_bindings![plain(KeyCode::Home)], + jump_bottom: default_bindings![plain(KeyCode::End)], + close: default_bindings![plain(KeyCode::Char('q')), ctrl(KeyCode::Char('c'))], + close_transcript: default_bindings![ctrl(KeyCode::Char('t'))], + }, + list: ListKeymap { + move_up: default_bindings![ + plain(KeyCode::Up), + ctrl(KeyCode::Char('p')), + plain(KeyCode::Char('k')) + ], + move_down: default_bindings![ + plain(KeyCode::Down), + ctrl(KeyCode::Char('n')), + plain(KeyCode::Char('j')) + ], + accept: default_bindings![plain(KeyCode::Enter)], + cancel: default_bindings![plain(KeyCode::Esc)], + }, + approval: ApprovalKeymap { + open_fullscreen: default_bindings![ + ctrl(KeyCode::Char('a')), + raw(KeyBinding::new( + KeyCode::Char('a'), + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + )) + ], + open_thread: default_bindings![plain(KeyCode::Char('o'))], + approve: default_bindings![plain(KeyCode::Char('y'))], + approve_for_session: default_bindings![plain(KeyCode::Char('a'))], + approve_for_prefix: default_bindings![plain(KeyCode::Char('p'))], + deny: default_bindings![plain(KeyCode::Char('d'))], + decline: default_bindings![plain(KeyCode::Esc), plain(KeyCode::Char('n'))], + cancel: default_bindings![plain(KeyCode::Char('c'))], + }, + } + } + + /// Reject ambiguous bindings in scopes that are evaluated together. + /// + /// We validate in multiple passes because runtime handling has mixed + /// precedence: + /// + /// 1. `app` actions can shadow composer actions because app checks run + /// before forwarding to the composer. + /// 2. Contexts with hard-coded sequence behavior, such as edit-previous + /// backtracking, intentionally stay outside this configurable keymap. + fn validate_conflicts(&self) -> Result<(), String> { + validate_unique( + "app", + [ + ("open_transcript", self.app.open_transcript.as_slice()), + ( + "open_external_editor", + self.app.open_external_editor.as_slice(), + ), + ("copy", self.app.copy.as_slice()), + ("clear_terminal", self.app.clear_terminal.as_slice()), + ( + "chat.decrease_reasoning_effort", + self.chat.decrease_reasoning_effort.as_slice(), + ), + ( + "chat.increase_reasoning_effort", + self.chat.increase_reasoning_effort.as_slice(), + ), + ( + "chat.edit_queued_message", + self.chat.edit_queued_message.as_slice(), + ), + ("composer.submit", self.composer.submit.as_slice()), + ("composer.queue", self.composer.queue.as_slice()), + ( + "composer.toggle_shortcuts", + self.composer.toggle_shortcuts.as_slice(), + ), + ( + "composer.history_search_previous", + self.composer.history_search_previous.as_slice(), + ), + ( + "composer.history_search_next", + self.composer.history_search_next.as_slice(), + ), + ], + )?; + + validate_no_reserved( + "main", + [ + ("open_transcript", self.app.open_transcript.as_slice()), + ( + "open_external_editor", + self.app.open_external_editor.as_slice(), + ), + ("copy", self.app.copy.as_slice()), + ("clear_terminal", self.app.clear_terminal.as_slice()), + ( + "chat.decrease_reasoning_effort", + self.chat.decrease_reasoning_effort.as_slice(), + ), + ( + "chat.increase_reasoning_effort", + self.chat.increase_reasoning_effort.as_slice(), + ), + ( + "chat.edit_queued_message", + self.chat.edit_queued_message.as_slice(), + ), + ("composer.submit", self.composer.submit.as_slice()), + ("composer.queue", self.composer.queue.as_slice()), + ( + "composer.toggle_shortcuts", + self.composer.toggle_shortcuts.as_slice(), + ), + ( + "composer.history_search_previous", + self.composer.history_search_previous.as_slice(), + ), + ( + "composer.history_search_next", + self.composer.history_search_next.as_slice(), + ), + ], + MAIN_RESERVED_BINDINGS, + )?; + + validate_no_shadow( + "app", + [ + ("open_transcript", self.app.open_transcript.as_slice()), + ( + "open_external_editor", + self.app.open_external_editor.as_slice(), + ), + ("copy", self.app.copy.as_slice()), + ("clear_terminal", self.app.clear_terminal.as_slice()), + ], + [ + ("list.move_up", self.list.move_up.as_slice()), + ("list.move_down", self.list.move_down.as_slice()), + ("list.accept", self.list.accept.as_slice()), + ("list.cancel", self.list.cancel.as_slice()), + ( + "approval.open_fullscreen", + self.approval.open_fullscreen.as_slice(), + ), + ("approval.open_thread", self.approval.open_thread.as_slice()), + ("approval.approve", self.approval.approve.as_slice()), + ( + "approval.approve_for_session", + self.approval.approve_for_session.as_slice(), + ), + ( + "approval.approve_for_prefix", + self.approval.approve_for_prefix.as_slice(), + ), + ("approval.deny", self.approval.deny.as_slice()), + ("approval.decline", self.approval.decline.as_slice()), + ("approval.cancel", self.approval.cancel.as_slice()), + ], + )?; + + // While the composer is focused, these main-surface handlers always + // consume matching keys before the event reaches the textarea editor. + validate_no_shadow_with_allowed_overlaps( + "main", + [ + ("open_transcript", self.app.open_transcript.as_slice()), + ( + "open_external_editor", + self.app.open_external_editor.as_slice(), + ), + ("copy", self.app.copy.as_slice()), + ("clear_terminal", self.app.clear_terminal.as_slice()), + ( + "chat.decrease_reasoning_effort", + self.chat.decrease_reasoning_effort.as_slice(), + ), + ( + "chat.increase_reasoning_effort", + self.chat.increase_reasoning_effort.as_slice(), + ), + ("composer.submit", self.composer.submit.as_slice()), + ( + "composer.history_search_previous", + self.composer.history_search_previous.as_slice(), + ), + ], + [ + ( + "editor.insert_newline", + self.editor.insert_newline.as_slice(), + ), + ("editor.move_left", self.editor.move_left.as_slice()), + ("editor.move_right", self.editor.move_right.as_slice()), + ("editor.move_up", self.editor.move_up.as_slice()), + ("editor.move_down", self.editor.move_down.as_slice()), + ( + "editor.move_word_left", + self.editor.move_word_left.as_slice(), + ), + ( + "editor.move_word_right", + self.editor.move_word_right.as_slice(), + ), + ( + "editor.move_line_start", + self.editor.move_line_start.as_slice(), + ), + ("editor.move_line_end", self.editor.move_line_end.as_slice()), + ( + "editor.delete_backward", + self.editor.delete_backward.as_slice(), + ), + ( + "editor.delete_forward", + self.editor.delete_forward.as_slice(), + ), + ( + "editor.delete_backward_word", + self.editor.delete_backward_word.as_slice(), + ), + ( + "editor.delete_forward_word", + self.editor.delete_forward_word.as_slice(), + ), + ( + "editor.kill_line_start", + self.editor.kill_line_start.as_slice(), + ), + ("editor.kill_line_end", self.editor.kill_line_end.as_slice()), + ("editor.yank", self.editor.yank.as_slice()), + ], + [( + "composer.submit", + "editor.insert_newline", + key_hint::plain(KeyCode::Enter), + )], + )?; + + validate_unique( + "editor", + [ + ("insert_newline", self.editor.insert_newline.as_slice()), + ("move_left", self.editor.move_left.as_slice()), + ("move_right", self.editor.move_right.as_slice()), + ("move_up", self.editor.move_up.as_slice()), + ("move_down", self.editor.move_down.as_slice()), + ("move_word_left", self.editor.move_word_left.as_slice()), + ("move_word_right", self.editor.move_word_right.as_slice()), + ("move_line_start", self.editor.move_line_start.as_slice()), + ("move_line_end", self.editor.move_line_end.as_slice()), + ("delete_backward", self.editor.delete_backward.as_slice()), + ("delete_forward", self.editor.delete_forward.as_slice()), + ( + "delete_backward_word", + self.editor.delete_backward_word.as_slice(), + ), + ( + "delete_forward_word", + self.editor.delete_forward_word.as_slice(), + ), + ("kill_line_start", self.editor.kill_line_start.as_slice()), + ("kill_line_end", self.editor.kill_line_end.as_slice()), + ("yank", self.editor.yank.as_slice()), + ], + )?; + + validate_unique( + "pager", + [ + ("scroll_up", self.pager.scroll_up.as_slice()), + ("scroll_down", self.pager.scroll_down.as_slice()), + ("page_up", self.pager.page_up.as_slice()), + ("page_down", self.pager.page_down.as_slice()), + ("half_page_up", self.pager.half_page_up.as_slice()), + ("half_page_down", self.pager.half_page_down.as_slice()), + ("jump_top", self.pager.jump_top.as_slice()), + ("jump_bottom", self.pager.jump_bottom.as_slice()), + ("close", self.pager.close.as_slice()), + ("close_transcript", self.pager.close_transcript.as_slice()), + ], + )?; + + validate_no_reserved( + "pager", + [ + ("scroll_up", self.pager.scroll_up.as_slice()), + ("scroll_down", self.pager.scroll_down.as_slice()), + ("page_up", self.pager.page_up.as_slice()), + ("page_down", self.pager.page_down.as_slice()), + ("half_page_up", self.pager.half_page_up.as_slice()), + ("half_page_down", self.pager.half_page_down.as_slice()), + ("jump_top", self.pager.jump_top.as_slice()), + ("jump_bottom", self.pager.jump_bottom.as_slice()), + ("close", self.pager.close.as_slice()), + ("close_transcript", self.pager.close_transcript.as_slice()), + ], + TRANSCRIPT_BACKTRACK_RESERVED_BINDINGS, + )?; + + validate_unique( + "list", + [ + ("move_up", self.list.move_up.as_slice()), + ("move_down", self.list.move_down.as_slice()), + ("accept", self.list.accept.as_slice()), + ("cancel", self.list.cancel.as_slice()), + ], + )?; + + validate_unique( + "approval", + [ + ("open_fullscreen", self.approval.open_fullscreen.as_slice()), + ("open_thread", self.approval.open_thread.as_slice()), + ("approve", self.approval.approve.as_slice()), + ( + "approve_for_session", + self.approval.approve_for_session.as_slice(), + ), + ( + "approve_for_prefix", + self.approval.approve_for_prefix.as_slice(), + ), + ("deny", self.approval.deny.as_slice()), + ("decline", self.approval.decline.as_slice()), + ("cancel", self.approval.cancel.as_slice()), + ], + )?; + + let mut seen: HashMap<(KeyCode, KeyModifiers), &'static str> = HashMap::new(); + for (action, bindings) in [ + ("list.move_up", self.list.move_up.as_slice()), + ("list.move_down", self.list.move_down.as_slice()), + ("list.accept", self.list.accept.as_slice()), + ("list.cancel", self.list.cancel.as_slice()), + ( + "approval.open_fullscreen", + self.approval.open_fullscreen.as_slice(), + ), + ("approval.open_thread", self.approval.open_thread.as_slice()), + ("approval.approve", self.approval.approve.as_slice()), + ( + "approval.approve_for_session", + self.approval.approve_for_session.as_slice(), + ), + ( + "approval.approve_for_prefix", + self.approval.approve_for_prefix.as_slice(), + ), + ("approval.deny", self.approval.deny.as_slice()), + ("approval.decline", self.approval.decline.as_slice()), + ("approval.cancel", self.approval.cancel.as_slice()), + ] { + for binding in bindings { + let key = binding.parts(); + if let Some(previous) = seen.insert(key, action) { + // Approval overlays intentionally reserve Esc as a stable + // cancellation path even though decline options may also + // display it in contexts where that is safe. + if previous == "list.cancel" + && action == "approval.decline" + && key == (KeyCode::Esc, KeyModifiers::NONE) + { + continue; + } + return Err(format!( + "Ambiguous approval overlay keymap bindings: `{previous}` and `{action}` use the same key. \ +Set unique keys in `~/.codex/config.toml` and retry. \ +See the Codex keymap documentation for supported actions and examples." + )); + } + } + } + + Ok(()) + } +} + +/// Reject duplicate keys inside one effective context map. +/// +/// This intentionally allows the same key across different contexts; handlers +/// only evaluate one context at a time. +fn validate_unique( + context: &str, + pairs: [(&'static str, &[KeyBinding]); N], +) -> Result<(), String> { + let mut seen: HashMap<(KeyCode, KeyModifiers), &'static str> = HashMap::new(); + for (action, bindings) in pairs { + for binding in bindings { + let key = binding.parts(); + if let Some(previous) = seen.insert(key, action) { + return Err(format!( + "Ambiguous `tui.keymap.{context}` bindings: `{previous}` and `{action}` use the same key. \ +Set unique keys in `~/.codex/config.toml` and retry. \ +See the Codex keymap documentation for supported actions and examples." + )); + } + } + } + Ok(()) +} + +fn validate_no_shadow( + context: &str, + primary: [(&'static str, &[KeyBinding]); N], + shadowed: [(&'static str, &[KeyBinding]); M], +) -> Result<(), String> { + validate_no_shadow_with_allowed_overlaps(context, primary, shadowed, []) +} + +fn validate_no_shadow_with_allowed_overlaps( + context: &str, + primary: [(&'static str, &[KeyBinding]); N], + shadowed: [(&'static str, &[KeyBinding]); M], + allowed_overlaps: [(&'static str, &'static str, KeyBinding); A], +) -> Result<(), String> { + let mut seen: HashMap<(KeyCode, KeyModifiers), &'static str> = HashMap::new(); + for (action, bindings) in primary { + for binding in bindings { + seen.insert(binding.parts(), action); + } + } + for (action, bindings) in shadowed { + for binding in bindings { + let key = binding.parts(); + if let Some(previous) = seen.get(&key) { + if allowed_overlaps.iter().any( + |(allowed_primary, allowed_shadowed, allowed_binding)| { + *allowed_primary == *previous + && *allowed_shadowed == action + && allowed_binding.parts() == key + }, + ) { + continue; + } + return Err(format!( + "Ambiguous `tui.keymap.{context}` bindings: `{previous}` shadows `{action}` with the same key. \ +Set unique keys in `~/.codex/config.toml` and retry. \ +See the Codex keymap documentation for supported actions and examples." + )); + } + } + } + Ok(()) +} + +fn validate_no_reserved( + context: &str, + pairs: [(&'static str, &[KeyBinding]); N], + reserved: &[(&'static str, KeyBinding)], +) -> Result<(), String> { + for (action, bindings) in pairs { + for binding in bindings { + let key = binding.parts(); + if let Some((reserved_action, _)) = reserved + .iter() + .find(|(_, reserved_binding)| reserved_binding.parts() == key) + { + return Err(format!( + "Ambiguous `tui.keymap.{context}` bindings: `{action}` uses a key reserved by `{reserved_action}`. \ +Set a different key in `~/.codex/config.toml` and retry. \ +See the Codex keymap documentation for supported actions and examples." + )); + } + } + } + Ok(()) +} + +const MAIN_RESERVED_BINDINGS: &[(&str, KeyBinding)] = &[ + ( + "fixed.interrupt_or_quit", + key_hint::ctrl(KeyCode::Char('c')), + ), + ("fixed.quit", key_hint::ctrl(KeyCode::Char('d'))), + ("fixed.paste_image", key_hint::ctrl(KeyCode::Char('v'))), + ("fixed.paste_image", key_hint::ctrl_alt(KeyCode::Char('v'))), + ( + "fixed.cycle_collaboration_mode", + key_hint::shift(KeyCode::Tab), + ), + ( + "fixed.return_from_side_or_backtrack", + key_hint::plain(KeyCode::Esc), + ), + ("fixed.previous_agent", key_hint::alt(KeyCode::Left)), + ("fixed.next_agent", key_hint::alt(KeyCode::Right)), + ("fixed.slash_command", key_hint::plain(KeyCode::Char('/'))), + ("fixed.shell_command", key_hint::plain(KeyCode::Char('!'))), + ("fixed.file_paths", key_hint::plain(KeyCode::Char('@'))), + ( + "fixed.connector_mentions", + key_hint::plain(KeyCode::Char('$')), + ), +]; + +const TRANSCRIPT_BACKTRACK_RESERVED_BINDINGS: &[(&str, KeyBinding)] = &[ + ( + "fixed.transcript_edit_previous", + key_hint::plain(KeyCode::Esc), + ), + ( + "fixed.transcript_edit_previous", + key_hint::plain(KeyCode::Left), + ), + ( + "fixed.transcript_edit_next", + key_hint::plain(KeyCode::Right), + ), + ( + "fixed.transcript_confirm_edit", + key_hint::plain(KeyCode::Enter), + ), +]; + +/// Resolve one action with context -> global -> default precedence. +/// +/// `path` should be the context-specific config path so parser errors point +/// users at the override they attempted to set. +/// +/// A configured empty list is authoritative: it returns an empty binding set +/// and does not continue to the global or built-in fallback. This is what makes +/// explicit unbinding work for globally reusable actions like composer submit. +fn resolve_bindings_with_global_fallback( + configured: Option<&KeybindingsSpec>, + global: Option<&KeybindingsSpec>, + fallback: &[KeyBinding], + path: &str, +) -> Result, String> { + if let Some(configured) = configured { + return parse_bindings(configured, path); + } + if let Some(global) = global { + return parse_bindings(global, path); + } + Ok(fallback.to_vec()) +} + +/// Resolve one action binding in a context without global fallback. +/// +/// Missing values inherit from the built-in fallback; configured values, including +/// empty lists, replace that fallback for the action. +fn resolve_bindings( + configured: Option<&KeybindingsSpec>, + fallback: &[KeyBinding], + path: &str, +) -> Result, String> { + let Some(spec) = configured else { + return Ok(fallback.to_vec()); + }; + parse_bindings(spec, path) +} + +/// Parse one keybinding value (`string` or `list[string]`) into concrete bindings. +/// +/// Duplicate entries are de-duplicated while preserving first-seen order so the +/// first key can remain the primary UI hint. +fn parse_bindings(spec: &KeybindingsSpec, path: &str) -> Result, String> { + let mut parsed = Vec::new(); + for raw in spec.specs() { + let binding = parse_keybinding(raw.as_str()).ok_or_else(|| { + format!( + "Invalid `{path}` = `{}`. Use values like `ctrl-a`, `shift-enter`, or `page-down`. \ +See the Codex keymap documentation for supported actions and examples.", + raw.as_str() + ) + })?; + + if !parsed.contains(&binding) { + parsed.push(binding); + } + } + Ok(parsed) +} + +/// Parse one normalized keybinding spec such as `ctrl-a` or `shift-enter`. +/// +/// Specs are expected to be normalized by config deserialization, but this +/// parser remains strict to keep runtime error messages precise. +fn parse_keybinding(spec: &str) -> Option { + let mut parts = spec.split('-'); + let mut modifiers = KeyModifiers::NONE; + let mut key_name = None; + + for part in parts.by_ref() { + match part { + "ctrl" => modifiers |= KeyModifiers::CONTROL, + "alt" => modifiers |= KeyModifiers::ALT, + "shift" => modifiers |= KeyModifiers::SHIFT, + other => { + key_name = Some(other.to_string()); + break; + } + } + } + + let mut key_name = key_name?; + for trailing in parts { + key_name.push('-'); + key_name.push_str(trailing); + } + + let key = match key_name.as_str() { + "enter" => KeyCode::Enter, + "tab" => KeyCode::Tab, + "backspace" => KeyCode::Backspace, + "esc" => KeyCode::Esc, + "delete" => KeyCode::Delete, + "up" => KeyCode::Up, + "down" => KeyCode::Down, + "left" => KeyCode::Left, + "right" => KeyCode::Right, + "home" => KeyCode::Home, + "end" => KeyCode::End, + "page-up" => KeyCode::PageUp, + "page-down" => KeyCode::PageDown, + "space" => KeyCode::Char(' '), + other if other.len() == 1 => KeyCode::Char(char::from(other.as_bytes()[0])), + other if other.starts_with('f') => { + let number = other[1..].parse::().ok()?; + if (1..=12).contains(&number) { + KeyCode::F(number) + } else { + return None; + } + } + _ => return None, + }; + + Some(KeyBinding::new(key, modifiers)) +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_config::types::KeybindingSpec; + + fn one(spec: &str) -> KeybindingsSpec { + KeybindingsSpec::One(KeybindingSpec(spec.to_string())) + } + + fn expect_conflict(keymap: &TuiKeymap, first: &str, second: &str) { + let err = RuntimeKeymap::from_config(keymap).expect_err("expected conflict"); + assert!(err.contains(first)); + assert!(err.contains(second)); + } + + #[test] + fn parses_canonical_binding() { + let binding = parse_keybinding("ctrl-alt-shift-a").expect("binding should parse"); + assert_eq!(binding.parts().0, KeyCode::Char('a')); + assert_eq!( + binding.parts().1, + KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT + ); + } + + #[test] + fn rejects_shadowing_composer_binding_in_app_scope() { + let mut keymap = TuiKeymap::default(); + keymap.global.open_transcript = Some(one("ctrl-t")); + keymap.composer.submit = Some(one("ctrl-t")); + + let err = RuntimeKeymap::from_config(&keymap).expect_err("expected shadowing conflict"); + assert!(err.contains("composer.submit")); + assert!(err.contains("open_transcript")); + } + + #[test] + fn rejects_shadowing_composer_queue_in_app_scope() { + let mut keymap = TuiKeymap::default(); + keymap.global.open_external_editor = Some(one("ctrl-g")); + keymap.composer.queue = Some(one("ctrl-g")); + + let err = RuntimeKeymap::from_config(&keymap).expect_err("expected shadowing conflict"); + assert!(err.contains("composer.queue")); + assert!(err.contains("open_external_editor")); + } + + #[test] + fn rejects_shadowing_composer_toggle_shortcuts_in_app_scope() { + let mut keymap = TuiKeymap::default(); + keymap.global.open_transcript = Some(one("ctrl-k")); + keymap.composer.toggle_shortcuts = Some(one("ctrl-k")); + + let err = RuntimeKeymap::from_config(&keymap).expect_err("expected shadowing conflict"); + assert!(err.contains("composer.toggle_shortcuts")); + assert!(err.contains("open_transcript")); + } + + #[test] + fn rejects_shadowing_editor_binding_in_main_scope() { + let mut keymap = TuiKeymap::default(); + keymap.composer.submit = Some(one("ctrl-j")); + keymap.editor.insert_newline = Some(one("ctrl-j")); + + let err = RuntimeKeymap::from_config(&keymap).expect_err("expected shadowing conflict"); + assert!(err.contains("composer.submit")); + assert!(err.contains("editor.insert_newline")); + } + + #[test] + fn rejects_shadowing_editor_binding_from_outer_main_handler() { + let mut keymap = TuiKeymap::default(); + keymap.global.copy = Some(one("ctrl-y")); + keymap.editor.yank = Some(one("ctrl-y")); + + let err = RuntimeKeymap::from_config(&keymap).expect_err("expected shadowing conflict"); + assert!(err.contains("copy")); + assert!(err.contains("editor.yank")); + } + + #[test] + fn rejects_shadowing_approval_binding_in_app_scope() { + let mut keymap = TuiKeymap::default(); + keymap.global.open_transcript = Some(one("y")); + + let err = RuntimeKeymap::from_config(&keymap).expect_err("expected shadowing conflict"); + assert!(err.contains("approval.approve")); + assert!(err.contains("open_transcript")); + } + + #[test] + fn rejects_shadowing_list_binding_in_app_scope() { + let mut keymap = TuiKeymap::default(); + keymap.global.copy = Some(one("down")); + + let err = RuntimeKeymap::from_config(&keymap).expect_err("expected shadowing conflict"); + assert!(err.contains("list.move_down")); + assert!(err.contains("copy")); + } + + #[test] + fn supports_string_or_array_bindings() { + let mut keymap = TuiKeymap::default(); + keymap.composer.submit = Some(KeybindingsSpec::Many(vec![ + KeybindingSpec("ctrl-enter".to_string()), + KeybindingSpec("meta-enter".to_string()), + ])); + + let err = RuntimeKeymap::from_config(&keymap).expect_err("meta is not a valid modifier"); + assert!(err.contains("tui.keymap.composer.submit")); + + keymap.composer.submit = Some(KeybindingsSpec::Many(vec![ + KeybindingSpec("ctrl-enter".to_string()), + KeybindingSpec("alt-enter".to_string()), + ])); + + let runtime = RuntimeKeymap::from_config(&keymap).expect("valid multi-binding"); + assert_eq!(runtime.composer.submit.len(), 2); + } + + #[test] + fn deduplicates_repeated_bindings_while_preserving_first_seen_order() { + let mut keymap = TuiKeymap::default(); + keymap.composer.submit = Some(KeybindingsSpec::Many(vec![ + KeybindingSpec("ctrl-enter".to_string()), + KeybindingSpec("ctrl-enter".to_string()), + KeybindingSpec("alt-enter".to_string()), + ])); + + let runtime = RuntimeKeymap::from_config(&keymap).expect("valid multi-binding"); + assert_eq!( + runtime.composer.submit, + vec![ + key_hint::ctrl(KeyCode::Enter), + key_hint::alt(KeyCode::Enter) + ] + ); + } + + #[test] + fn falls_back_to_global_binding_when_context_override_is_not_set() { + let mut keymap = TuiKeymap::default(); + keymap.global.queue = Some(one("ctrl-q")); + + let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse"); + assert_eq!( + runtime.composer.queue, + vec![key_hint::ctrl(KeyCode::Char('q'))] + ); + } + + #[test] + fn invalid_global_open_transcript_binding_reports_global_path() { + let mut keymap = TuiKeymap::default(); + keymap.global.open_transcript = Some(one("meta-t")); + + let err = RuntimeKeymap::from_config(&keymap).expect_err("expected parse error"); + assert!(err.contains("tui.keymap.global.open_transcript")); + } + + #[test] + fn invalid_global_open_external_editor_binding_reports_global_path() { + let mut keymap = TuiKeymap::default(); + keymap.global.open_external_editor = Some(one("meta-g")); + + let err = RuntimeKeymap::from_config(&keymap).expect_err("expected parse error"); + assert!(err.contains("tui.keymap.global.open_external_editor")); + } + + #[test] + fn default_copy_binding_is_ctrl_o() { + let runtime = RuntimeKeymap::defaults(); + assert_eq!(runtime.app.copy, vec![key_hint::ctrl(KeyCode::Char('o'))]); + } + + #[test] + fn defaults_include_reassignable_main_surface_actions() { + let runtime = RuntimeKeymap::defaults(); + + assert_eq!( + runtime.app.clear_terminal, + vec![key_hint::ctrl(KeyCode::Char('l'))] + ); + assert_eq!( + runtime.chat.decrease_reasoning_effort, + vec![key_hint::alt(KeyCode::Char(','))] + ); + assert_eq!( + runtime.chat.increase_reasoning_effort, + vec![key_hint::alt(KeyCode::Char('.'))] + ); + assert_eq!( + runtime.chat.edit_queued_message, + vec![key_hint::alt(KeyCode::Up), key_hint::shift(KeyCode::Left)] + ); + assert_eq!( + runtime.composer.history_search_previous, + vec![key_hint::ctrl(KeyCode::Char('r'))] + ); + assert_eq!( + runtime.composer.history_search_next, + vec![key_hint::ctrl(KeyCode::Char('s'))] + ); + } + + #[test] + fn invalid_global_copy_binding_reports_global_path() { + let mut keymap = TuiKeymap::default(); + keymap.global.copy = Some(one("meta-o")); + + let err = RuntimeKeymap::from_config(&keymap).expect_err("expected parse error"); + assert!(err.contains("tui.keymap.global.copy")); + } + + #[test] + fn rejects_conflicting_editor_bindings() { + let mut keymap = TuiKeymap::default(); + keymap.editor.move_left = Some(one("ctrl-h")); + keymap.editor.move_right = Some(one("ctrl-h")); + + expect_conflict(&keymap, "move_left", "move_right"); + } + + #[test] + fn rejects_conflicting_pager_bindings() { + let mut keymap = TuiKeymap::default(); + keymap.pager.scroll_up = Some(one("ctrl-u")); + keymap.pager.scroll_down = Some(one("ctrl-u")); + + expect_conflict(&keymap, "scroll_up", "scroll_down"); + } + + #[test] + fn rejects_conflicting_list_bindings() { + let mut keymap = TuiKeymap::default(); + keymap.list.move_up = Some(one("up")); + keymap.list.move_down = Some(one("up")); + + expect_conflict(&keymap, "move_up", "move_down"); + } + + #[test] + fn rejects_conflicting_approval_bindings() { + let mut keymap = TuiKeymap::default(); + keymap.approval.approve = Some(one("y")); + keymap.approval.decline = Some(one("y")); + + expect_conflict(&keymap, "approve", "decline"); + } + + #[test] + fn rejects_conflicting_approval_deny_binding() { + let mut keymap = TuiKeymap::default(); + keymap.approval.approve = Some(one("y")); + keymap.approval.deny = Some(one("y")); + + expect_conflict(&keymap, "approve", "deny"); + } + + #[test] + fn rejects_conflicting_approval_overlay_accept_binding() { + let mut keymap = TuiKeymap::default(); + keymap.list.accept = Some(one("y")); + + expect_conflict(&keymap, "list.accept", "approval.approve"); + } + + #[test] + fn rejects_conflicting_approval_overlay_cancel_binding() { + let mut keymap = TuiKeymap::default(); + keymap.list.cancel = Some(one("c")); + + expect_conflict(&keymap, "list.cancel", "approval.cancel"); + } + + #[test] + fn reassignable_fixed_shortcuts_conflict_until_original_action_is_unbound() { + let mut keymap = TuiKeymap::default(); + keymap.global.copy = Some(one("alt-.")); + + expect_conflict(&keymap, "copy", "chat.increase_reasoning_effort"); + + keymap.chat.increase_reasoning_effort = Some(KeybindingsSpec::Many(vec![])); + let runtime = RuntimeKeymap::from_config(&keymap).expect("remapped key should be free"); + assert_eq!(runtime.app.copy, vec![key_hint::alt(KeyCode::Char('.'))]); + } + + #[test] + fn rejects_main_bindings_that_collide_with_remaining_fixed_shortcuts() { + let mut keymap = TuiKeymap::default(); + keymap.composer.submit = Some(one("ctrl-v")); + + expect_conflict(&keymap, "composer.submit", "fixed.paste_image"); + } + + #[test] + fn rejects_pager_bindings_that_collide_with_transcript_backtrack_keys() { + let mut keymap = TuiKeymap::default(); + keymap.pager.close = Some(one("left")); + + expect_conflict(&keymap, "close", "fixed.transcript_edit_previous"); + } + + #[test] + fn parses_function_keys_and_rejects_out_of_range_function_keys() { + assert_eq!( + parse_keybinding("f1").map(|binding| binding.parts()), + Some((KeyCode::F(1), KeyModifiers::NONE)) + ); + assert_eq!(parse_keybinding("f13"), None); + } + + #[test] + fn parses_all_named_non_character_keys() { + let cases = [ + ("tab", KeyCode::Tab), + ("backspace", KeyCode::Backspace), + ("esc", KeyCode::Esc), + ("delete", KeyCode::Delete), + ("up", KeyCode::Up), + ("down", KeyCode::Down), + ("left", KeyCode::Left), + ("right", KeyCode::Right), + ("home", KeyCode::Home), + ("end", KeyCode::End), + ("page-up", KeyCode::PageUp), + ("page-down", KeyCode::PageDown), + ("space", KeyCode::Char(' ')), + ]; + + for (spec, expected_key) in cases { + assert_eq!( + parse_keybinding(spec).map(|binding| binding.parts()), + Some((expected_key, KeyModifiers::NONE)), + "failed to parse {spec}" + ); + } + } + + #[test] + fn rejects_modifier_only_and_nonnumeric_function_key_specs() { + assert_eq!(parse_keybinding("ctrl"), None); + assert_eq!(parse_keybinding("ff"), None); + } + + #[test] + fn explicit_empty_array_unbinds_action() { + let mut keymap = TuiKeymap::default(); + keymap.composer.toggle_shortcuts = Some(KeybindingsSpec::Many(vec![])); + let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse"); + assert!(runtime.composer.toggle_shortcuts.is_empty()); + } + + #[test] + fn default_editor_insert_newline_includes_shift_enter() { + let runtime = RuntimeKeymap::defaults(); + assert!( + runtime + .editor + .insert_newline + .contains(&key_hint::shift(KeyCode::Enter)) + ); + } + + #[test] + fn default_editor_delete_forward_word_includes_alt_d() { + let runtime = RuntimeKeymap::defaults(); + assert!( + runtime + .editor + .delete_forward_word + .contains(&key_hint::alt(KeyCode::Char('d'))) + ); + } + + #[test] + fn default_composer_toggle_shortcuts_includes_shift_question_mark() { + let runtime = RuntimeKeymap::defaults(); + assert!( + runtime + .composer + .toggle_shortcuts + .contains(&key_hint::shift(KeyCode::Char('?'))) + ); + } + + #[test] + fn default_approval_open_fullscreen_includes_ctrl_shift_a() { + let runtime = RuntimeKeymap::defaults(); + assert!(runtime.approval.open_fullscreen.contains(&KeyBinding::new( + KeyCode::Char('a'), + KeyModifiers::CONTROL | KeyModifiers::SHIFT + ))); + } + + #[test] + fn primary_binding_returns_first_or_none() { + let bindings = vec![ + key_hint::ctrl(KeyCode::Char('a')), + key_hint::shift(KeyCode::Char('b')), + ]; + assert_eq!( + primary_binding(&bindings), + Some(key_hint::ctrl(KeyCode::Char('a'))) + ); + assert_eq!(primary_binding(&[]), None); + } + + #[test] + fn defaults_pass_conflict_validation() { + RuntimeKeymap::defaults() + .validate_conflicts() + .expect("default keymap should be conflict free"); + } +} diff --git a/codex-rs/tui/src/keymap_setup.rs b/codex-rs/tui/src/keymap_setup.rs new file mode 100644 index 0000000000..cc6a769a5c --- /dev/null +++ b/codex-rs/tui/src/keymap_setup.rs @@ -0,0 +1,1652 @@ +//! Guided keymap remapping UI for `/keymap`. +//! +//! Pick an action, choose whether to set or remove its root-level custom +//! binding, then validate and persist the resulting runtime keymap. + +mod actions; +mod picker; + +pub(crate) use picker::KEYMAP_PICKER_VIEW_ID; +pub(crate) use picker::build_keymap_picker_params; +pub(crate) use picker::build_keymap_picker_params_for_selected_action; + +use codex_config::types::KeybindingSpec; +use codex_config::types::KeybindingsSpec; +use codex_config::types::TuiKeymap; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; + +use crate::app_event::AppEvent; +use crate::app_event::KeymapEditIntent; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::BottomPaneView; +use crate::bottom_pane::CancellationEvent; +use crate::bottom_pane::ColumnWidthMode; +use crate::bottom_pane::SelectionItem; +use crate::bottom_pane::SelectionViewParams; +use crate::bottom_pane::popup_consts::standard_popup_hint_line; +use crate::keymap::RuntimeKeymap; +use crate::render::renderable::ColumnRenderable; +use crate::render::renderable::Renderable; +use actions::KEYMAP_ACTIONS; +use actions::action_label; +use actions::binding_slot; +use actions::bindings_for_action; +use actions::format_binding_summary; + +pub(crate) const KEYMAP_ACTION_MENU_VIEW_ID: &str = "keymap-action-menu"; +pub(crate) const KEYMAP_REPLACE_BINDING_MENU_VIEW_ID: &str = "keymap-replace-binding-menu"; + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum KeymapEditOutcome { + Updated { + keymap_config: Box, + bindings: Vec, + message: String, + }, + Unchanged { + message: String, + }, +} + +fn key_binding_span(binding: &str) -> ratatui::text::Span<'static> { + if binding == "unbound" { + binding.to_string().dim() + } else { + binding.to_string().cyan() + } +} + +fn keymap_action_menu_hint_line() -> Line<'static> { + Line::from(vec![ + "enter".cyan(), + " select · ".dim(), + "esc".cyan(), + " back".dim(), + ]) +} + +fn open_capture_action( + context: String, + action: String, + intent: KeymapEditIntent, +) -> Box { + Box::new(move |tx| { + tx.send(AppEvent::OpenKeymapCapture { + context: context.clone(), + action: action.clone(), + intent: intent.clone(), + }); + }) +} + +fn action_menu_item( + name: &str, + description: &str, + selected_description: String, + context: &str, + action: &str, + intent: KeymapEditIntent, +) -> SelectionItem { + SelectionItem { + name: name.to_string(), + description: Some(description.to_string()), + selected_description: Some(selected_description), + actions: vec![open_capture_action( + context.to_string(), + action.to_string(), + intent, + )], + ..Default::default() + } +} + +pub(crate) fn build_keymap_action_menu_params( + context: String, + action: String, + runtime_keymap: &RuntimeKeymap, + keymap_config: &TuiKeymap, +) -> SelectionViewParams { + let current_bindings = + active_binding_specs(runtime_keymap, &context, &action).unwrap_or_else(|_| Vec::new()); + let current_binding = if current_bindings.is_empty() { + "unbound".to_string() + } else { + current_bindings.join(", ") + }; + let active_binding_count = current_bindings.len(); + let custom_binding = has_custom_binding(keymap_config, &context, &action).unwrap_or(false); + let descriptor = KEYMAP_ACTIONS + .iter() + .find(|descriptor| descriptor.context == context && descriptor.action == action); + let context_label = descriptor + .map(|descriptor| descriptor.context_label) + .unwrap_or(context.as_str()) + .to_string(); + let description = descriptor + .map(|descriptor| descriptor.description) + .unwrap_or("Configure this shortcut."); + let remove_disabled_reason = (!custom_binding) + .then(|| "There is no custom root binding for this action to remove.".to_string()); + let label = action_label(&action); + let remove_context = context.clone(); + let remove_action = action.clone(); + let config_path = format!("tui.keymap.{context}.{action}"); + let source = if custom_binding { + "Custom root override".cyan() + } else { + "Default keymap".dim() + }; + let mut header = ColumnRenderable::new(); + header.push(Line::from("Edit Shortcut".bold())); + header.push(Line::from(vec![ + label.bold(), + " · ".dim(), + context_label.dim(), + ])); + header.push(Line::from(vec![ + "Current ".dim(), + key_binding_span(¤t_binding), + " · ".dim(), + source, + ])); + header.push(Line::from(vec![ + "Config ".dim(), + format!("`{config_path}`").cyan(), + ])); + header.push(Line::from(description.to_string().dim())); + + let mut items = Vec::new(); + match active_binding_count { + 0 => { + items.push(action_menu_item( + "Set key", + "Capture a key for this unbound action.", + "Capture one key and bind this action.".to_string(), + &context, + &action, + KeymapEditIntent::ReplaceAll, + )); + } + 1 => { + items.push(action_menu_item( + "Replace binding", + "Capture a replacement key.", + format!("Capture one key and replace `{current_binding}`."), + &context, + &action, + KeymapEditIntent::ReplaceAll, + )); + items.push(action_menu_item( + "Add alternate binding", + "Keep the current binding and add another key.", + format!("Capture one key and keep `{current_binding}` as an alternate."), + &context, + &action, + KeymapEditIntent::AddAlternate, + )); + } + _ => { + let replace_one_context = context.clone(); + let replace_one_action = action.clone(); + items.push(SelectionItem { + name: "Replace one binding...".to_string(), + description: Some("Choose which existing binding to replace.".to_string()), + selected_description: Some( + "Pick one current binding, then capture its replacement.".to_string(), + ), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenKeymapReplaceBindingMenu { + context: replace_one_context.clone(), + action: replace_one_action.clone(), + }); + })], + ..Default::default() + }); + items.push(action_menu_item( + "Replace all bindings", + "Replace every current binding with one key.", + format!("Capture one key and replace `{current_binding}`."), + &context, + &action, + KeymapEditIntent::ReplaceAll, + )); + items.push(action_menu_item( + "Add alternate binding", + "Keep current bindings and add another key.", + format!("Capture one key and keep `{current_binding}`."), + &context, + &action, + KeymapEditIntent::AddAlternate, + )); + } + } + items.push(SelectionItem { + name: "Remove custom binding".to_string(), + description: Some(if custom_binding { + "Restore the default keymap binding.".to_string() + } else { + "No root override to remove.".to_string() + }), + selected_description: Some( + "Delete the root override and use the default keymap again.".to_string(), + ), + disabled_reason: remove_disabled_reason, + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::KeymapCleared { + context: remove_context.clone(), + action: remove_action.clone(), + }); + })], + ..Default::default() + }); + items.push(SelectionItem { + name: "Back to shortcuts".to_string(), + description: Some("Return to the shortcut list.".to_string()), + dismiss_on_select: true, + ..Default::default() + }); + + SelectionViewParams { + view_id: Some(KEYMAP_ACTION_MENU_VIEW_ID), + header: Box::new(header), + footer_note: Some(Line::from(vec![ + "Changes write the root ".dim(), + "`tui.keymap.*`".cyan(), + " override.".dim(), + ])), + footer_hint: Some(keymap_action_menu_hint_line()), + items, + col_width_mode: ColumnWidthMode::Fixed, + ..Default::default() + } +} + +pub(crate) fn build_keymap_replace_binding_menu_params( + context: String, + action: String, + runtime_keymap: &RuntimeKeymap, +) -> SelectionViewParams { + let bindings = active_binding_specs(runtime_keymap, &context, &action).unwrap_or_default(); + let label = action_label(&action); + let mut header = ColumnRenderable::new(); + header.push(Line::from("Replace Binding".bold())); + header.push(Line::from(vec![ + label.bold(), + " · ".dim(), + format!("{context}.{action}").dim(), + ])); + header.push(Line::from("Choose the binding to replace.".dim())); + + let items = bindings + .into_iter() + .map(|binding| { + let capture_context = context.clone(); + let capture_action = action.clone(); + let old_key = binding.clone(); + SelectionItem { + name: binding.clone(), + description: Some("Replace this binding.".to_string()), + selected_description: Some(format!("Capture a new key to replace `{binding}`.")), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenKeymapCapture { + context: capture_context.clone(), + action: capture_action.clone(), + intent: KeymapEditIntent::ReplaceOne { + old_key: old_key.clone(), + }, + }); + })], + dismiss_on_select: true, + ..Default::default() + } + }) + .collect(); + + SelectionViewParams { + view_id: Some(KEYMAP_REPLACE_BINDING_MENU_VIEW_ID), + header: Box::new(header), + footer_hint: Some(keymap_action_menu_hint_line()), + items, + col_width_mode: ColumnWidthMode::Fixed, + ..Default::default() + } +} + +pub(crate) fn build_keymap_conflict_params( + context: String, + action: String, + key: String, + intent: KeymapEditIntent, + error: String, +) -> SelectionViewParams { + let retry_context = context.clone(); + let retry_action = action.clone(); + let retry_intent = intent; + SelectionViewParams { + title: Some("Shortcut Conflict".to_string()), + subtitle: Some(format!("{context}.{action} cannot use `{key}`.")), + footer_note: Some(Line::from(error)), + footer_hint: Some(standard_popup_hint_line()), + items: vec![ + SelectionItem { + name: "Pick another key".to_string(), + description: Some("Return to key capture for this action.".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenKeymapCapture { + context: retry_context.clone(), + action: retry_action.clone(), + intent: retry_intent.clone(), + }); + })], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Cancel".to_string(), + description: Some("Leave keymap unchanged.".to_string()), + dismiss_on_select: true, + ..Default::default() + }, + ], + col_width_mode: ColumnWidthMode::Fixed, + ..Default::default() + } +} + +pub(crate) fn build_keymap_capture_view( + context: String, + action: String, + intent: KeymapEditIntent, + runtime_keymap: &RuntimeKeymap, + app_event_tx: AppEventSender, +) -> KeymapCaptureView { + let current_binding = format_binding_summary( + bindings_for_action(runtime_keymap, &context, &action).unwrap_or(&[]), + ); + let label = action_label(&action); + KeymapCaptureView::new( + context, + action, + intent, + label, + current_binding, + app_event_tx, + ) +} + +#[cfg(test)] +fn keymap_with_replacement( + keymap: &TuiKeymap, + context: &str, + action: &str, + key: &str, +) -> Result { + keymap_with_bindings(keymap, context, action, &[key.to_string()]) +} + +pub(crate) fn keymap_with_edit( + keymap: &TuiKeymap, + runtime_keymap: &RuntimeKeymap, + context: &str, + action: &str, + key: &str, + intent: &KeymapEditIntent, +) -> Result { + let current_bindings = active_binding_specs(runtime_keymap, context, action)?; + let next_bindings = match intent { + KeymapEditIntent::ReplaceAll => vec![key.to_string()], + KeymapEditIntent::AddAlternate => { + if current_bindings.iter().any(|binding| binding == key) { + return Ok(KeymapEditOutcome::Unchanged { + message: format!("No change: `{context}.{action}` already uses `{key}`."), + }); + } + let mut bindings = current_bindings.clone(); + bindings.push(key.to_string()); + bindings + } + KeymapEditIntent::ReplaceOne { old_key } => { + if !current_bindings.iter().any(|binding| binding == old_key) { + return Err(format!( + "`{context}.{action}` no longer uses `{old_key}`. Reopen /keymap and choose a binding again." + )); + } + let bindings = current_bindings + .iter() + .map(|binding| { + if binding == old_key { + key.to_string() + } else { + binding.clone() + } + }) + .collect::>(); + dedup_bindings(bindings) + } + }; + + if next_bindings == current_bindings { + return Ok(KeymapEditOutcome::Unchanged { + message: format!("No change: `{context}.{action}` already uses `{key}`."), + }); + } + + let message = match intent { + KeymapEditIntent::ReplaceAll => format!("Remapped `{context}.{action}` to `{key}`."), + KeymapEditIntent::AddAlternate => format!("Added `{key}` to `{context}.{action}`."), + KeymapEditIntent::ReplaceOne { old_key } => { + format!("Replaced `{old_key}` with `{key}` for `{context}.{action}`.") + } + }; + + Ok(KeymapEditOutcome::Updated { + keymap_config: Box::new(keymap_with_bindings( + keymap, + context, + action, + &next_bindings, + )?), + bindings: next_bindings, + message, + }) +} + +fn keymap_with_bindings( + keymap: &TuiKeymap, + context: &str, + action: &str, + keys: &[String], +) -> Result { + let mut keymap = keymap.clone(); + let slot = binding_slot(&mut keymap, context, action).ok_or_else(|| { + format!("Unknown keymap action `{context}.{action}`. Reopen /keymap and choose an action.") + })?; + *slot = Some(match keys { + [key] => KeybindingsSpec::One(KeybindingSpec(key.clone())), + keys => KeybindingsSpec::Many( + keys.iter() + .map(|key| KeybindingSpec(key.clone())) + .collect::>(), + ), + }); + Ok(keymap) +} + +pub(crate) fn active_binding_specs( + runtime_keymap: &RuntimeKeymap, + context: &str, + action: &str, +) -> Result, String> { + let bindings = bindings_for_action(runtime_keymap, context, action).ok_or_else(|| { + format!("Unknown keymap action `{context}.{action}`. Reopen /keymap and choose an action.") + })?; + bindings + .iter() + .map(|binding| binding_to_config_key_spec(*binding)) + .collect() +} + +fn dedup_bindings(bindings: Vec) -> Vec { + bindings.into_iter().fold(Vec::new(), |mut deduped, key| { + if !deduped.contains(&key) { + deduped.push(key); + } + deduped + }) +} + +pub(crate) fn keymap_without_custom_binding( + keymap: &TuiKeymap, + context: &str, + action: &str, +) -> Result { + let mut keymap = keymap.clone(); + let slot = binding_slot(&mut keymap, context, action).ok_or_else(|| { + format!("Unknown keymap action `{context}.{action}`. Reopen /keymap and choose an action.") + })?; + *slot = None; + Ok(keymap) +} + +fn has_custom_binding(keymap: &TuiKeymap, context: &str, action: &str) -> Result { + let mut keymap = keymap.clone(); + let slot = binding_slot(&mut keymap, context, action).ok_or_else(|| { + format!("Unknown keymap action `{context}.{action}`. Reopen /keymap and choose an action.") + })?; + Ok(slot.is_some()) +} + +pub(crate) struct KeymapCaptureView { + context: String, + action: String, + intent: KeymapEditIntent, + label: String, + current_binding: String, + app_event_tx: AppEventSender, + complete: bool, + error_message: Option, +} + +impl KeymapCaptureView { + fn new( + context: String, + action: String, + intent: KeymapEditIntent, + label: String, + current_binding: String, + app_event_tx: AppEventSender, + ) -> Self { + Self { + context, + action, + intent, + label, + current_binding, + app_event_tx, + complete: false, + error_message: None, + } + } + + fn lines(&self, width: u16) -> Vec> { + let wrap_width = usize::from(width.max(1)); + let mut lines = vec![ + Line::from("Remap Shortcut".bold()), + Line::from(vec![ + "Action: ".dim(), + self.label.clone().into(), + " ".into(), + format!("{}.{}", self.context, self.action).dim(), + ]), + Line::from(vec!["Current: ".dim(), self.current_binding.clone().cyan()]), + Line::from("Press the new key now. Esc cancels.".dim()), + ]; + + if let Some(error) = &self.error_message { + lines.push(Line::from("")); + let options = textwrap::Options::new(wrap_width) + .initial_indent("Error: ") + .subsequent_indent(" "); + lines.extend( + textwrap::wrap(error, options) + .into_iter() + .map(|line| Line::from(line.into_owned().red())), + ); + } + + lines + } +} + +impl Renderable for KeymapCaptureView { + fn render(&self, area: Rect, buf: &mut Buffer) { + Paragraph::new(self.lines(area.width)).render(area, buf); + } + + fn desired_height(&self, width: u16) -> u16 { + self.lines(width).len() as u16 + } +} + +impl BottomPaneView for KeymapCaptureView { + fn handle_key_event(&mut self, key_event: KeyEvent) { + if key_event.kind == KeyEventKind::Release { + return; + } + + if key_event.code == KeyCode::Esc { + self.complete = true; + return; + } + + match key_event_to_config_key_spec(key_event) { + Ok(key) => { + self.app_event_tx.send(AppEvent::KeymapCaptured { + context: self.context.clone(), + action: self.action.clone(), + key, + intent: self.intent.clone(), + }); + self.complete = true; + } + Err(error) => { + self.error_message = Some(error); + } + } + } + + fn is_complete(&self) -> bool { + self.complete + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + self.complete = true; + CancellationEvent::Handled + } + + fn prefer_esc_to_handle_key_event(&self) -> bool { + true + } +} + +fn key_event_to_config_key_spec(key_event: KeyEvent) -> Result { + key_parts_to_config_key_spec(key_event.code, key_event.modifiers) +} + +fn binding_to_config_key_spec(binding: crate::key_hint::KeyBinding) -> Result { + let (code, modifiers) = binding.parts(); + key_parts_to_config_key_spec(code, modifiers) +} + +fn key_parts_to_config_key_spec( + code: KeyCode, + mut modifiers: KeyModifiers, +) -> Result { + let supported_modifiers = KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT; + if !modifiers.difference(supported_modifiers).is_empty() { + return Err( + "Only ctrl, alt, and shift modifiers can be stored in `tui.keymap`.".to_string(), + ); + } + + let key = match code { + KeyCode::Enter => "enter".to_string(), + KeyCode::Tab => "tab".to_string(), + KeyCode::Backspace => "backspace".to_string(), + KeyCode::Esc => "esc".to_string(), + KeyCode::Delete => "delete".to_string(), + KeyCode::Up => "up".to_string(), + KeyCode::Down => "down".to_string(), + KeyCode::Left => "left".to_string(), + KeyCode::Right => "right".to_string(), + KeyCode::Home => "home".to_string(), + KeyCode::End => "end".to_string(), + KeyCode::PageUp => "page-up".to_string(), + KeyCode::PageDown => "page-down".to_string(), + KeyCode::F(number) if (1..=12).contains(&number) => format!("f{number}"), + KeyCode::F(_) => { + return Err( + "Only function keys F1 through F12 can be stored in `tui.keymap`.".to_string(), + ); + } + KeyCode::Char(' ') => "space".to_string(), + KeyCode::Char(mut ch) => { + if ch == '-' { + return Err("The `-` key cannot be represented in `tui.keymap` yet.".to_string()); + } + if !ch.is_ascii() || ch.is_ascii_control() { + return Err("Only printable ASCII keys can be stored in `tui.keymap`.".to_string()); + } + if ch.is_ascii_uppercase() { + modifiers.insert(KeyModifiers::SHIFT); + ch = ch.to_ascii_lowercase(); + } + ch.to_string() + } + _ => { + return Err("That key is not supported by `tui.keymap`.".to_string()); + } + }; + + let mut parts = Vec::new(); + if modifiers.contains(KeyModifiers::CONTROL) { + parts.push("ctrl".to_string()); + } + if modifiers.contains(KeyModifiers::ALT) { + parts.push("alt".to_string()); + } + if modifiers.contains(KeyModifiers::SHIFT) { + parts.push("shift".to_string()); + } + parts.push(key); + Ok(parts.join("-")) +} + +#[cfg(test)] +mod tests { + use super::picker::KEYMAP_ALL_TAB_ID; + use super::picker::KEYMAP_COMMON_TAB_ID; + use super::picker::KEYMAP_CUSTOM_TAB_ID; + use super::picker::KEYMAP_UNBOUND_TAB_ID; + use super::*; + use crate::bottom_pane::BottomPane; + use crate::bottom_pane::BottomPaneParams; + use crate::bottom_pane::ListSelectionView; + use crate::bottom_pane::SelectionTab; + use crate::tui::FrameRequester; + use insta::assert_snapshot; + use pretty_assertions::assert_eq; + use ratatui::buffer::Buffer; + use tokio::sync::mpsc::UnboundedReceiver; + use tokio::sync::mpsc::unbounded_channel; + + fn app_event_sender() -> AppEventSender { + let (tx, _rx) = unbounded_channel(); + AppEventSender::new(tx) + } + + fn render_capture(view: &KeymapCaptureView, width: u16, height: u16) -> Buffer { + let area = Rect::new(0, 0, width, height); + let mut buf = Buffer::empty(area); + view.render(area, &mut buf); + buf + } + + fn render_picker(params: SelectionViewParams, width: u16) -> String { + let view = + ListSelectionView::new(params, app_event_sender(), RuntimeKeymap::defaults().list); + render_picker_from_view(&view, width) + } + + fn render_picker_from_view(view: &ListSelectionView, width: u16) -> String { + let height = view.desired_height(width); + let area = Rect::new(0, 0, width, height); + let mut buf = Buffer::empty(area); + view.render(area, &mut buf); + render_buffer(&buf) + } + + fn render_buffer(buf: &Buffer) -> String { + let area = buf.area(); + (0..area.height) + .map(|row| { + let mut line = String::new(); + for col in 0..area.width { + let symbol = buf[(col, row)].symbol(); + if symbol.is_empty() { + line.push(' '); + } else { + line.push_str(symbol); + } + } + line.trim_end().to_string() + }) + .collect::>() + .join("\n") + } + + fn test_pane() -> (BottomPane, AppEventSender, UnboundedReceiver) { + let (tx_raw, rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx.clone(), + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: false, + skills: Some(Vec::new()), + }); + (pane, tx, rx) + } + + fn selection_tab<'a>(params: &'a SelectionViewParams, id: &str) -> &'a SelectionTab { + params + .tabs + .iter() + .find(|tab| tab.id == id) + .expect("selection tab") + } + + fn selection_item<'a>(params: &'a SelectionViewParams, name: &str) -> &'a SelectionItem { + params + .items + .iter() + .find(|item| item.name == name) + .expect("selection item") + } + + fn action_menu_rows(params: &SelectionViewParams) -> String { + params + .items + .iter() + .map(|item| { + format!( + "{} | {} | {}", + item.name, + item.description.as_deref().unwrap_or_default(), + item.disabled_reason.as_deref().unwrap_or("enabled") + ) + }) + .collect::>() + .join("\n") + } + + #[test] + fn picker_covers_every_replaceable_action() { + let runtime = RuntimeKeymap::defaults(); + let params = build_keymap_picker_params(&runtime, &TuiKeymap::default()); + let all_tab = selection_tab(¶ms, KEYMAP_ALL_TAB_ID); + + assert!(params.items.is_empty()); + assert_eq!(all_tab.items.len(), KEYMAP_ACTIONS.len()); + assert!( + all_tab.items.iter().all(|item| !item.dismiss_on_select), + "keymap picker should stay open behind the action menu" + ); + assert!(KEYMAP_ACTIONS.iter().all(|descriptor| { + binding_slot( + &mut TuiKeymap::default(), + descriptor.context, + descriptor.action, + ) + .is_some() + })); + assert!(KEYMAP_ACTIONS.iter().all(|descriptor| { + bindings_for_action(&runtime, descriptor.context, descriptor.action).is_some() + })); + } + + #[test] + fn picker_common_tab_lists_curated_actions() { + let runtime = RuntimeKeymap::defaults(); + let params = build_keymap_picker_params(&runtime, &TuiKeymap::default()); + let common_tab = selection_tab(¶ms, KEYMAP_COMMON_TAB_ID); + let actions = common_tab + .items + .iter() + .map(|item| { + item.search_value + .as_deref() + .unwrap_or_default() + .split_whitespace() + .take(2) + .collect::>() + .join(".") + }) + .collect::>(); + + assert_eq!( + actions, + vec![ + "Composer.submit", + "Editor.insert_newline", + "Composer.queue", + "Global.open_external_editor", + "Global.copy", + "Editor.delete_backward_word", + "Editor.delete_forward_word", + "Editor.move_word_left", + "Editor.move_word_right", + "Global.open_transcript", + "Pager.close", + "Pager.page_up", + "Pager.page_down", + "Approval.open_fullscreen", + "Approval.approve", + "Approval.approve_for_session", + "Approval.decline", + "Approval.cancel", + ] + ); + } + + #[test] + fn picker_approval_tab_lists_all_approval_actions() { + let runtime = RuntimeKeymap::defaults(); + let params = build_keymap_picker_params(&runtime, &TuiKeymap::default()); + let approval_tab = selection_tab(¶ms, "approval-shortcuts"); + let actions = approval_tab + .items + .iter() + .map(|item| { + item.search_value + .as_deref() + .unwrap_or_default() + .split_whitespace() + .take(2) + .collect::>() + .join(".") + }) + .collect::>(); + + assert_eq!( + actions, + vec![ + "Approval.open_fullscreen", + "Approval.open_thread", + "Approval.approve", + "Approval.approve_for_session", + "Approval.approve_for_prefix", + "Approval.deny", + "Approval.decline", + "Approval.cancel", + ] + ); + } + + #[test] + fn picker_content_snapshot() { + let runtime = RuntimeKeymap::defaults(); + let params = build_keymap_picker_params(&runtime, &TuiKeymap::default()); + let all_tab = selection_tab(¶ms, KEYMAP_ALL_TAB_ID); + let snapshot = params + .tabs + .iter() + .map(|tab| { + let selectable = tab.items.iter().filter(|item| !item.is_disabled).count(); + format!("tab: {} ({selectable} selectable)", tab.label) + }) + .chain(all_tab.items.iter().take(12).map(|item| { + format!( + "{} | {} | {}", + item.name, + item.description.as_deref().unwrap_or_default(), + item.search_value.as_deref().unwrap_or_default() + ) + })) + .collect::>() + .join("\n"); + + assert_snapshot!("keymap_picker_first_actions", snapshot); + } + + #[test] + fn picker_customized_tab_contains_root_overrides() { + let keymap = + keymap_with_replacement(&TuiKeymap::default(), "composer", "submit", "ctrl-enter") + .expect("replace binding"); + let runtime = RuntimeKeymap::from_config(&keymap).expect("runtime keymap"); + let params = build_keymap_picker_params(&runtime, &keymap); + let custom_tab = selection_tab(¶ms, KEYMAP_CUSTOM_TAB_ID); + let composer_tab = selection_tab(¶ms, "composer-shortcuts"); + + assert_eq!( + custom_tab + .items + .iter() + .map(|item| item.name.as_str()) + .collect::>(), + vec!["Submit"] + ); + assert!( + composer_tab + .items + .iter() + .any(|item| item.description.as_deref() == Some("ctrl-enter")) + ); + } + + #[test] + fn picker_unbound_tab_lists_default_unbound_actions() { + let runtime = RuntimeKeymap::defaults(); + let params = build_keymap_picker_params(&runtime, &TuiKeymap::default()); + let unbound_tab = selection_tab(¶ms, KEYMAP_UNBOUND_TAB_ID); + + assert_eq!(unbound_tab.items.len(), 1); + assert_eq!(unbound_tab.items[0].name, "No unbound shortcuts"); + assert!(unbound_tab.items[0].is_disabled); + } + + #[test] + fn picker_selected_action_starts_on_matching_all_tab_row() { + let runtime = RuntimeKeymap::defaults(); + let params = build_keymap_picker_params_for_selected_action( + &runtime, + &TuiKeymap::default(), + "composer", + "submit", + ); + let all_tab = selection_tab(¶ms, KEYMAP_ALL_TAB_ID); + + assert_eq!(params.initial_tab_id.as_deref(), Some(KEYMAP_ALL_TAB_ID)); + assert_eq!( + params.initial_selected_idx, + all_tab.items.iter().position(|item| item.name == "Submit") + ); + } + + #[test] + fn picker_all_tab_items_remain_searchable() { + let runtime = RuntimeKeymap::defaults(); + let params = build_keymap_picker_params(&runtime, &TuiKeymap::default()); + let all_tab = selection_tab(¶ms, KEYMAP_ALL_TAB_ID); + let snapshot = all_tab + .items + .iter() + .take(12) + .map(|item| { + format!( + "{} | {} | {}", + item.name, + item.description.as_deref().unwrap_or_default(), + item.search_value.as_deref().unwrap_or_default() + ) + }) + .collect::>() + .join("\n"); + + assert_snapshot!("keymap_picker_all_tab_search", snapshot); + } + + #[test] + fn picker_wide_render_snapshot() { + let runtime = RuntimeKeymap::defaults(); + let params = build_keymap_picker_params(&runtime, &TuiKeymap::default()); + + assert_snapshot!("keymap_picker_wide", render_picker(params, /*width*/ 120)); + } + + #[test] + fn picker_narrow_render_snapshot() { + let runtime = RuntimeKeymap::defaults(); + let params = build_keymap_picker_params(&runtime, &TuiKeymap::default()); + + assert_snapshot!("keymap_picker_narrow", render_picker(params, /*width*/ 78)); + } + + #[test] + fn picker_custom_render_snapshot() { + let keymap = + keymap_with_replacement(&TuiKeymap::default(), "composer", "submit", "ctrl-enter") + .expect("replace binding"); + let runtime = RuntimeKeymap::from_config(&keymap).expect("runtime keymap"); + let params = build_keymap_picker_params(&runtime, &keymap); + + assert_snapshot!("keymap_picker_custom", render_picker(params, /*width*/ 120)); + } + + #[test] + fn picker_narrow_uses_compact_tabs() { + let runtime = RuntimeKeymap::defaults(); + let params = build_keymap_picker_params(&runtime, &TuiKeymap::default()); + let rendered = render_picker(params, /*width*/ 78); + + assert!(rendered.contains("Keymap")); + assert!(rendered.contains("Open Transcript")); + assert!(rendered.contains("ctrl-t")); + assert!(!rendered.contains("Selected Action")); + assert!(!rendered.contains("Source: default keymap")); + } + + #[test] + fn action_menu_content_snapshot() { + let unbound_keymap = keymap_with_bindings(&TuiKeymap::default(), "global", "copy", &[]) + .expect("unbound copy"); + let unbound_runtime = RuntimeKeymap::from_config(&unbound_keymap).expect("runtime keymap"); + let unbound_params = build_keymap_action_menu_params( + "global".to_string(), + "copy".to_string(), + &unbound_runtime, + &unbound_keymap, + ); + + let single_keymap = + keymap_with_replacement(&TuiKeymap::default(), "composer", "submit", "ctrl-enter") + .expect("replace binding"); + let single_runtime = RuntimeKeymap::from_config(&single_keymap).expect("runtime keymap"); + let single_params = build_keymap_action_menu_params( + "composer".to_string(), + "submit".to_string(), + &single_runtime, + &single_keymap, + ); + + let multi_keymap = keymap_with_bindings( + &TuiKeymap::default(), + "composer", + "submit", + &["ctrl-enter".to_string(), "alt-enter".to_string()], + ) + .expect("multi binding"); + let multi_runtime = RuntimeKeymap::from_config(&multi_keymap).expect("runtime keymap"); + let multi_params = build_keymap_action_menu_params( + "composer".to_string(), + "submit".to_string(), + &multi_runtime, + &multi_keymap, + ); + let replace_params = build_keymap_replace_binding_menu_params( + "composer".to_string(), + "submit".to_string(), + &multi_runtime, + ); + let snapshot = [ + "unbound:", + &action_menu_rows(&unbound_params), + "", + "single:", + &action_menu_rows(&single_params), + "", + "multi:", + &action_menu_rows(&multi_params), + "", + "replace picker:", + &action_menu_rows(&replace_params), + ] + .join("\n"); + + assert_snapshot!("keymap_action_menu", snapshot); + } + + #[test] + fn action_menu_disables_clear_when_action_has_no_custom_binding() { + let runtime = RuntimeKeymap::defaults(); + let params = build_keymap_action_menu_params( + "composer".to_string(), + "submit".to_string(), + &runtime, + &TuiKeymap::default(), + ); + + assert_eq!(params.view_id, Some(KEYMAP_ACTION_MENU_VIEW_ID)); + let replace = selection_item(¶ms, "Replace binding"); + let add_alternate = selection_item(¶ms, "Add alternate binding"); + let remove = selection_item(¶ms, "Remove custom binding"); + let back = selection_item(¶ms, "Back to shortcuts"); + assert_eq!( + remove.disabled_reason.as_deref(), + Some("There is no custom root binding for this action to remove.") + ); + assert!( + !replace.dismiss_on_select, + "replace should keep the action menu under key capture" + ); + assert!( + !add_alternate.dismiss_on_select, + "add alternate should keep the action menu under key capture" + ); + assert!(!remove.dismiss_on_select, "clear-key waits for save result"); + assert!( + back.dismiss_on_select, + "back should dismiss the action menu" + ); + } + + #[test] + fn capture_view_snapshot() { + let view = KeymapCaptureView::new( + "composer".to_string(), + "submit".to_string(), + KeymapEditIntent::ReplaceAll, + "Submit".to_string(), + "enter".to_string(), + app_event_sender(), + ); + + assert_snapshot!( + "keymap_capture_view", + format!("{:?}", render_capture(&view, /*width*/ 80, /*height*/ 8)) + ); + } + + #[test] + fn capture_completion_returns_to_selected_keymap_picker_row() { + let (mut pane, tx, mut rx) = test_pane(); + let runtime = RuntimeKeymap::defaults(); + pane.show_selection_view(build_keymap_picker_params(&runtime, &TuiKeymap::default())); + pane.show_selection_view(build_keymap_action_menu_params( + "composer".to_string(), + "submit".to_string(), + &runtime, + &TuiKeymap::default(), + )); + + pane.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let AppEvent::OpenKeymapCapture { + context, + action, + intent, + } = rx.try_recv().expect("open capture event") + else { + panic!("expected OpenKeymapCapture event"); + }; + assert_eq!(intent, KeymapEditIntent::ReplaceAll); + assert_eq!(pane.active_view_id(), Some(KEYMAP_ACTION_MENU_VIEW_ID)); + + pane.show_view(Box::new(build_keymap_capture_view( + context, action, intent, &runtime, tx, + ))); + pane.handle_key_event(KeyEvent::new(KeyCode::Char('K'), KeyModifiers::CONTROL)); + + let AppEvent::KeymapCaptured { + context, + action, + key, + intent, + } = rx.try_recv().expect("captured key event") + else { + panic!("expected KeymapCaptured event"); + }; + assert_eq!(context, "composer"); + assert_eq!(action, "submit"); + assert_eq!(key, "ctrl-shift-k"); + assert_eq!(intent, KeymapEditIntent::ReplaceAll); + assert_eq!(pane.active_view_id(), Some(KEYMAP_ACTION_MENU_VIEW_ID)); + + let keymap = + keymap_with_replacement(&TuiKeymap::default(), &context, &action, &key).unwrap(); + let runtime = RuntimeKeymap::from_config(&keymap).unwrap(); + let params = + build_keymap_picker_params_for_selected_action(&runtime, &keymap, &context, &action); + let selected_idx = params.initial_selected_idx; + assert!( + pane.replace_active_views_with_selection_view( + &[ + KEYMAP_PICKER_VIEW_ID, + KEYMAP_ACTION_MENU_VIEW_ID, + KEYMAP_REPLACE_BINDING_MENU_VIEW_ID, + ], + params + ), + "successful assignment should return to the main picker" + ); + assert_eq!(pane.active_view_id(), Some(KEYMAP_PICKER_VIEW_ID)); + assert_eq!( + pane.selected_index_for_active_view(KEYMAP_PICKER_VIEW_ID), + selected_idx + ); + + pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!( + pane.no_modal_or_popup_active(), + "the original picker should not remain behind the refreshed picker" + ); + } + + #[test] + fn clear_completion_returns_to_selected_keymap_picker_row() { + let (mut pane, _tx, mut rx) = test_pane(); + let keymap = + keymap_with_replacement(&TuiKeymap::default(), "composer", "submit", "ctrl-enter") + .unwrap(); + let runtime = RuntimeKeymap::from_config(&keymap).unwrap(); + pane.show_selection_view(build_keymap_picker_params(&runtime, &keymap)); + pane.show_selection_view(build_keymap_action_menu_params( + "composer".to_string(), + "submit".to_string(), + &runtime, + &keymap, + )); + + pane.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + pane.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + pane.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let AppEvent::KeymapCleared { context, action } = + rx.try_recv().expect("clear keymap event") + else { + panic!("expected KeymapCleared event"); + }; + assert_eq!(context, "composer"); + assert_eq!(action, "submit"); + assert_eq!(pane.active_view_id(), Some(KEYMAP_ACTION_MENU_VIEW_ID)); + + let runtime = RuntimeKeymap::defaults(); + let params = build_keymap_picker_params_for_selected_action( + &runtime, + &TuiKeymap::default(), + &context, + &action, + ); + let selected_idx = params.initial_selected_idx; + assert!( + pane.replace_active_views_with_selection_view( + &[ + KEYMAP_PICKER_VIEW_ID, + KEYMAP_ACTION_MENU_VIEW_ID, + KEYMAP_REPLACE_BINDING_MENU_VIEW_ID, + ], + params + ), + "successful clear should return to the main picker" + ); + assert_eq!(pane.active_view_id(), Some(KEYMAP_PICKER_VIEW_ID)); + assert_eq!( + pane.selected_index_for_active_view(KEYMAP_PICKER_VIEW_ID), + selected_idx + ); + + pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!( + pane.no_modal_or_popup_active(), + "the original picker should not remain behind the refreshed picker" + ); + } + + #[test] + fn replace_one_completion_drops_focused_keymap_submenus() { + let (mut pane, _tx, _rx) = test_pane(); + let runtime = RuntimeKeymap::defaults(); + pane.show_selection_view(build_keymap_picker_params(&runtime, &TuiKeymap::default())); + pane.show_selection_view(build_keymap_action_menu_params( + "composer".to_string(), + "toggle_shortcuts".to_string(), + &runtime, + &TuiKeymap::default(), + )); + pane.show_selection_view(build_keymap_replace_binding_menu_params( + "composer".to_string(), + "toggle_shortcuts".to_string(), + &runtime, + )); + assert_eq!( + pane.active_view_id(), + Some(KEYMAP_REPLACE_BINDING_MENU_VIEW_ID) + ); + + let params = build_keymap_picker_params_for_selected_action( + &runtime, + &TuiKeymap::default(), + "composer", + "toggle_shortcuts", + ); + assert!( + pane.replace_active_views_with_selection_view( + &[ + KEYMAP_PICKER_VIEW_ID, + KEYMAP_ACTION_MENU_VIEW_ID, + KEYMAP_REPLACE_BINDING_MENU_VIEW_ID, + ], + params + ), + "successful replace-one should return to the main picker" + ); + assert_eq!(pane.active_view_id(), Some(KEYMAP_PICKER_VIEW_ID)); + + pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!( + pane.no_modal_or_popup_active(), + "the parent action menu should not remain behind the picker" + ); + } + + #[test] + fn key_capture_serializes_modifier_order_for_config() { + let event = KeyEvent::new( + KeyCode::Char('K'), + KeyModifiers::CONTROL | KeyModifiers::ALT, + ); + + assert_eq!( + key_event_to_config_key_spec(event), + Ok("ctrl-alt-shift-k".to_string()) + ); + } + + #[test] + fn key_capture_serializes_special_keys() { + assert_eq!( + key_event_to_config_key_spec(KeyEvent::new(KeyCode::PageDown, KeyModifiers::SHIFT)), + Ok("shift-page-down".to_string()) + ); + } + + #[test] + fn key_capture_rejects_unrepresentable_keys() { + assert!( + key_event_to_config_key_spec(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE)) + .is_err() + ); + } + + #[test] + fn replacement_sets_single_binding() { + let keymap = + keymap_with_replacement(&TuiKeymap::default(), "composer", "submit", "ctrl-enter") + .expect("replace binding"); + + assert_eq!( + keymap.composer.submit, + Some(KeybindingsSpec::One(KeybindingSpec( + "ctrl-enter".to_string() + ))) + ); + } + + #[test] + fn replace_all_collapses_multi_binding_to_single() { + let keymap = keymap_with_bindings( + &TuiKeymap::default(), + "composer", + "submit", + &["ctrl-enter".to_string(), "alt-enter".to_string()], + ) + .expect("multi binding"); + let runtime = RuntimeKeymap::from_config(&keymap).expect("runtime keymap"); + let outcome = keymap_with_edit( + &keymap, + &runtime, + "composer", + "submit", + "ctrl-shift-enter", + &KeymapEditIntent::ReplaceAll, + ) + .expect("replace all"); + + let KeymapEditOutcome::Updated { + keymap_config, + bindings, + .. + } = outcome + else { + panic!("expected updated keymap"); + }; + assert_eq!(bindings, vec!["ctrl-shift-enter"]); + assert_eq!( + keymap_config.composer.submit, + Some(KeybindingsSpec::One(KeybindingSpec( + "ctrl-shift-enter".to_string() + ))) + ); + } + + #[test] + fn add_alternate_grows_single_binding() { + let runtime = RuntimeKeymap::defaults(); + let outcome = keymap_with_edit( + &TuiKeymap::default(), + &runtime, + "composer", + "submit", + "ctrl-enter", + &KeymapEditIntent::AddAlternate, + ) + .expect("add alternate"); + + let KeymapEditOutcome::Updated { + keymap_config, + bindings, + .. + } = outcome + else { + panic!("expected updated keymap"); + }; + assert_eq!(bindings, vec!["enter", "ctrl-enter"]); + assert_eq!( + keymap_config.composer.submit, + Some(KeybindingsSpec::Many(vec![ + KeybindingSpec("enter".to_string()), + KeybindingSpec("ctrl-enter".to_string()) + ])) + ); + } + + #[test] + fn add_alternate_grows_default_multi_binding() { + let runtime = RuntimeKeymap::defaults(); + let outcome = keymap_with_edit( + &TuiKeymap::default(), + &runtime, + "editor", + "move_left", + "ctrl-shift-b", + &KeymapEditIntent::AddAlternate, + ) + .expect("add alternate"); + + let KeymapEditOutcome::Updated { + keymap_config, + bindings, + .. + } = outcome + else { + panic!("expected updated keymap"); + }; + assert_eq!(bindings, vec!["left", "ctrl-b", "ctrl-shift-b"]); + assert_eq!( + keymap_config.editor.move_left, + Some(KeybindingsSpec::Many(vec![ + KeybindingSpec("left".to_string()), + KeybindingSpec("ctrl-b".to_string()), + KeybindingSpec("ctrl-shift-b".to_string()) + ])) + ); + } + + #[test] + fn add_alternate_duplicate_is_noop() { + let runtime = RuntimeKeymap::defaults(); + let outcome = keymap_with_edit( + &TuiKeymap::default(), + &runtime, + "composer", + "submit", + "enter", + &KeymapEditIntent::AddAlternate, + ) + .expect("duplicate alternate"); + + assert_eq!( + outcome, + KeymapEditOutcome::Unchanged { + message: "No change: `composer.submit` already uses `enter`.".to_string() + } + ); + } + + #[test] + fn replace_one_preserves_other_bindings() { + let keymap = keymap_with_bindings( + &TuiKeymap::default(), + "composer", + "submit", + &["ctrl-enter".to_string(), "alt-enter".to_string()], + ) + .expect("multi binding"); + let runtime = RuntimeKeymap::from_config(&keymap).expect("runtime keymap"); + let outcome = keymap_with_edit( + &keymap, + &runtime, + "composer", + "submit", + "ctrl-shift-enter", + &KeymapEditIntent::ReplaceOne { + old_key: "ctrl-enter".to_string(), + }, + ) + .expect("replace one"); + + let KeymapEditOutcome::Updated { + keymap_config, + bindings, + .. + } = outcome + else { + panic!("expected updated keymap"); + }; + assert_eq!(bindings, vec!["ctrl-shift-enter", "alt-enter"]); + assert_eq!( + keymap_config.composer.submit, + Some(KeybindingsSpec::Many(vec![ + KeybindingSpec("ctrl-shift-enter".to_string()), + KeybindingSpec("alt-enter".to_string()) + ])) + ); + } + + #[test] + fn replace_one_deduplicates_replacement() { + let keymap = keymap_with_bindings( + &TuiKeymap::default(), + "composer", + "submit", + &["ctrl-enter".to_string(), "alt-enter".to_string()], + ) + .expect("multi binding"); + let runtime = RuntimeKeymap::from_config(&keymap).expect("runtime keymap"); + let outcome = keymap_with_edit( + &keymap, + &runtime, + "composer", + "submit", + "alt-enter", + &KeymapEditIntent::ReplaceOne { + old_key: "ctrl-enter".to_string(), + }, + ) + .expect("replace one"); + + let KeymapEditOutcome::Updated { + keymap_config, + bindings, + .. + } = outcome + else { + panic!("expected updated keymap"); + }; + assert_eq!(bindings, vec!["alt-enter"]); + assert_eq!( + keymap_config.composer.submit, + Some(KeybindingsSpec::One(KeybindingSpec( + "alt-enter".to_string() + ))) + ); + } + + #[test] + fn replace_one_rejects_stale_old_key() { + let runtime = RuntimeKeymap::defaults(); + let err = keymap_with_edit( + &TuiKeymap::default(), + &runtime, + "composer", + "submit", + "ctrl-enter", + &KeymapEditIntent::ReplaceOne { + old_key: "alt-enter".to_string(), + }, + ) + .expect_err("stale old key"); + + assert!(err.contains("composer.submit")); + assert!(err.contains("alt-enter")); + } + + #[test] + fn clear_removes_custom_binding() { + let keymap = + keymap_with_replacement(&TuiKeymap::default(), "composer", "submit", "ctrl-enter") + .expect("replace binding"); + + assert_eq!(has_custom_binding(&keymap, "composer", "submit"), Ok(true)); + + let cleared = + keymap_without_custom_binding(&keymap, "composer", "submit").expect("clear binding"); + + assert_eq!(cleared.composer.submit, None); + assert_eq!( + has_custom_binding(&cleared, "composer", "submit"), + Ok(false) + ); + } + + #[test] + fn replacement_rejects_unknown_action() { + let err = keymap_with_replacement(&TuiKeymap::default(), "composer", "nope", "ctrl-enter") + .expect_err("unknown action"); + + assert!(err.contains("composer.nope")); + } +} diff --git a/codex-rs/tui/src/keymap_setup/actions.rs b/codex-rs/tui/src/keymap_setup/actions.rs new file mode 100644 index 0000000000..22bf7b217c --- /dev/null +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -0,0 +1,235 @@ +//! Catalog and accessors for keymap actions shown by `/keymap`. + +use std::collections::BTreeSet; + +use codex_config::types::KeybindingsSpec; +use codex_config::types::TuiKeymap; + +use crate::key_hint::KeyBinding; +use crate::keymap::RuntimeKeymap; + +#[derive(Clone, Copy, Debug)] +pub(super) struct KeymapActionDescriptor { + pub(super) context: &'static str, + pub(super) context_label: &'static str, + pub(super) action: &'static str, + pub(super) description: &'static str, +} + +const fn action( + context: &'static str, + context_label: &'static str, + action: &'static str, + description: &'static str, +) -> KeymapActionDescriptor { + KeymapActionDescriptor { + context, + context_label, + action, + description, + } +} + +#[rustfmt::skip] +pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[ + action("global", "Global", "open_transcript", "Open the transcript overlay."), + action("global", "Global", "open_external_editor", "Open the current draft in an external editor."), + action("global", "Global", "copy", "Copy the last agent response to the clipboard."), + action("global", "Global", "clear_terminal", "Clear the terminal UI."), + action("chat", "Chat", "decrease_reasoning_effort", "Decrease reasoning effort."), + action("chat", "Chat", "increase_reasoning_effort", "Increase reasoning effort."), + action("chat", "Chat", "edit_queued_message", "Edit the most recently queued message."), + action("composer", "Composer", "submit", "Submit the current composer draft."), + action("composer", "Composer", "queue", "Queue the draft while a task is running."), + action("composer", "Composer", "toggle_shortcuts", "Show or hide the composer shortcut overlay."), + action("composer", "Composer", "history_search_previous", "Open history search or move to the previous match."), + action("composer", "Composer", "history_search_next", "Move to the next history search match."), + action("editor", "Editor", "insert_newline", "Insert a newline in the editor."), + action("editor", "Editor", "move_left", "Move the cursor left."), + action("editor", "Editor", "move_right", "Move the cursor right."), + action("editor", "Editor", "move_up", "Move the cursor up."), + action("editor", "Editor", "move_down", "Move the cursor down."), + action("editor", "Editor", "move_word_left", "Move to the beginning of the previous word."), + action("editor", "Editor", "move_word_right", "Move to the end of the next word."), + action("editor", "Editor", "move_line_start", "Move to the beginning of the line."), + action("editor", "Editor", "move_line_end", "Move to the end of the line."), + action("editor", "Editor", "delete_backward", "Delete one grapheme to the left."), + action("editor", "Editor", "delete_forward", "Delete one grapheme to the right."), + action("editor", "Editor", "delete_backward_word", "Delete the previous word."), + action("editor", "Editor", "delete_forward_word", "Delete the next word."), + action("editor", "Editor", "kill_line_start", "Delete from cursor to line start."), + action("editor", "Editor", "kill_line_end", "Delete from cursor to line end."), + action("editor", "Editor", "yank", "Paste the kill buffer."), + action("pager", "Pager", "scroll_up", "Scroll up by one row."), + action("pager", "Pager", "scroll_down", "Scroll down by one row."), + action("pager", "Pager", "page_up", "Scroll up by one page."), + action("pager", "Pager", "page_down", "Scroll down by one page."), + action("pager", "Pager", "half_page_up", "Scroll up by half a page."), + action("pager", "Pager", "half_page_down", "Scroll down by half a page."), + action("pager", "Pager", "jump_top", "Jump to the beginning."), + action("pager", "Pager", "jump_bottom", "Jump to the end."), + action("pager", "Pager", "close", "Close the pager overlay."), + action("pager", "Pager", "close_transcript", "Close the transcript overlay."), + action("list", "List", "move_up", "Move list selection up."), + action("list", "List", "move_down", "Move list selection down."), + action("list", "List", "accept", "Accept the current list selection."), + action("list", "List", "cancel", "Cancel and close selection views."), + action("approval", "Approval", "open_fullscreen", "Open approval details fullscreen."), + action("approval", "Approval", "open_thread", "Open the approval source thread when available."), + action("approval", "Approval", "approve", "Approve the primary option."), + action("approval", "Approval", "approve_for_session", "Approve for the session when available."), + action("approval", "Approval", "approve_for_prefix", "Approve with an exec-policy prefix when available."), + action("approval", "Approval", "deny", "Choose the explicit deny option when available."), + action("approval", "Approval", "decline", "Decline and provide corrective guidance."), + action("approval", "Approval", "cancel", "Cancel an elicitation request."), +]; + +pub(super) fn action_label(action: &str) -> String { + action + .split('_') + .map(|word| { + let mut chars = word.chars(); + let Some(first) = chars.next() else { + return String::new(); + }; + format!("{}{}", first.to_ascii_uppercase(), chars.as_str()) + }) + .collect::>() + .join(" ") +} + +#[rustfmt::skip] +pub(super) fn binding_slot<'a>( + keymap: &'a mut TuiKeymap, + context: &str, + action: &str, +) -> Option<&'a mut Option> { + match (context, action) { + ("global", "open_transcript") => Some(&mut keymap.global.open_transcript), + ("global", "open_external_editor") => Some(&mut keymap.global.open_external_editor), + ("global", "copy") => Some(&mut keymap.global.copy), + ("global", "clear_terminal") => Some(&mut keymap.global.clear_terminal), + ("chat", "decrease_reasoning_effort") => Some(&mut keymap.chat.decrease_reasoning_effort), + ("chat", "increase_reasoning_effort") => Some(&mut keymap.chat.increase_reasoning_effort), + ("chat", "edit_queued_message") => Some(&mut keymap.chat.edit_queued_message), + ("composer", "submit") => Some(&mut keymap.composer.submit), + ("composer", "queue") => Some(&mut keymap.composer.queue), + ("composer", "toggle_shortcuts") => Some(&mut keymap.composer.toggle_shortcuts), + ("composer", "history_search_previous") => Some(&mut keymap.composer.history_search_previous), + ("composer", "history_search_next") => Some(&mut keymap.composer.history_search_next), + ("editor", "insert_newline") => Some(&mut keymap.editor.insert_newline), + ("editor", "move_left") => Some(&mut keymap.editor.move_left), + ("editor", "move_right") => Some(&mut keymap.editor.move_right), + ("editor", "move_up") => Some(&mut keymap.editor.move_up), + ("editor", "move_down") => Some(&mut keymap.editor.move_down), + ("editor", "move_word_left") => Some(&mut keymap.editor.move_word_left), + ("editor", "move_word_right") => Some(&mut keymap.editor.move_word_right), + ("editor", "move_line_start") => Some(&mut keymap.editor.move_line_start), + ("editor", "move_line_end") => Some(&mut keymap.editor.move_line_end), + ("editor", "delete_backward") => Some(&mut keymap.editor.delete_backward), + ("editor", "delete_forward") => Some(&mut keymap.editor.delete_forward), + ("editor", "delete_backward_word") => Some(&mut keymap.editor.delete_backward_word), + ("editor", "delete_forward_word") => Some(&mut keymap.editor.delete_forward_word), + ("editor", "kill_line_start") => Some(&mut keymap.editor.kill_line_start), + ("editor", "kill_line_end") => Some(&mut keymap.editor.kill_line_end), + ("editor", "yank") => Some(&mut keymap.editor.yank), + ("pager", "scroll_up") => Some(&mut keymap.pager.scroll_up), + ("pager", "scroll_down") => Some(&mut keymap.pager.scroll_down), + ("pager", "page_up") => Some(&mut keymap.pager.page_up), + ("pager", "page_down") => Some(&mut keymap.pager.page_down), + ("pager", "half_page_up") => Some(&mut keymap.pager.half_page_up), + ("pager", "half_page_down") => Some(&mut keymap.pager.half_page_down), + ("pager", "jump_top") => Some(&mut keymap.pager.jump_top), + ("pager", "jump_bottom") => Some(&mut keymap.pager.jump_bottom), + ("pager", "close") => Some(&mut keymap.pager.close), + ("pager", "close_transcript") => Some(&mut keymap.pager.close_transcript), + ("list", "move_up") => Some(&mut keymap.list.move_up), + ("list", "move_down") => Some(&mut keymap.list.move_down), + ("list", "accept") => Some(&mut keymap.list.accept), + ("list", "cancel") => Some(&mut keymap.list.cancel), + ("approval", "open_fullscreen") => Some(&mut keymap.approval.open_fullscreen), + ("approval", "open_thread") => Some(&mut keymap.approval.open_thread), + ("approval", "approve") => Some(&mut keymap.approval.approve), + ("approval", "approve_for_session") => Some(&mut keymap.approval.approve_for_session), + ("approval", "approve_for_prefix") => Some(&mut keymap.approval.approve_for_prefix), + ("approval", "deny") => Some(&mut keymap.approval.deny), + ("approval", "decline") => Some(&mut keymap.approval.decline), + ("approval", "cancel") => Some(&mut keymap.approval.cancel), + _ => None, + } +} + +#[rustfmt::skip] +pub(super) fn bindings_for_action<'a>( + runtime_keymap: &'a RuntimeKeymap, + context: &str, + action: &str, +) -> Option<&'a [KeyBinding]> { + match (context, action) { + ("global", "open_transcript") => Some(runtime_keymap.app.open_transcript.as_slice()), + ("global", "open_external_editor") => Some(runtime_keymap.app.open_external_editor.as_slice()), + ("global", "copy") => Some(runtime_keymap.app.copy.as_slice()), + ("global", "clear_terminal") => Some(runtime_keymap.app.clear_terminal.as_slice()), + ("chat", "decrease_reasoning_effort") => Some(runtime_keymap.chat.decrease_reasoning_effort.as_slice()), + ("chat", "increase_reasoning_effort") => Some(runtime_keymap.chat.increase_reasoning_effort.as_slice()), + ("chat", "edit_queued_message") => Some(runtime_keymap.chat.edit_queued_message.as_slice()), + ("composer", "submit") => Some(runtime_keymap.composer.submit.as_slice()), + ("composer", "queue") => Some(runtime_keymap.composer.queue.as_slice()), + ("composer", "toggle_shortcuts") => Some(runtime_keymap.composer.toggle_shortcuts.as_slice()), + ("composer", "history_search_previous") => Some(runtime_keymap.composer.history_search_previous.as_slice()), + ("composer", "history_search_next") => Some(runtime_keymap.composer.history_search_next.as_slice()), + ("editor", "insert_newline") => Some(runtime_keymap.editor.insert_newline.as_slice()), + ("editor", "move_left") => Some(runtime_keymap.editor.move_left.as_slice()), + ("editor", "move_right") => Some(runtime_keymap.editor.move_right.as_slice()), + ("editor", "move_up") => Some(runtime_keymap.editor.move_up.as_slice()), + ("editor", "move_down") => Some(runtime_keymap.editor.move_down.as_slice()), + ("editor", "move_word_left") => Some(runtime_keymap.editor.move_word_left.as_slice()), + ("editor", "move_word_right") => Some(runtime_keymap.editor.move_word_right.as_slice()), + ("editor", "move_line_start") => Some(runtime_keymap.editor.move_line_start.as_slice()), + ("editor", "move_line_end") => Some(runtime_keymap.editor.move_line_end.as_slice()), + ("editor", "delete_backward") => Some(runtime_keymap.editor.delete_backward.as_slice()), + ("editor", "delete_forward") => Some(runtime_keymap.editor.delete_forward.as_slice()), + ("editor", "delete_backward_word") => Some(runtime_keymap.editor.delete_backward_word.as_slice()), + ("editor", "delete_forward_word") => Some(runtime_keymap.editor.delete_forward_word.as_slice()), + ("editor", "kill_line_start") => Some(runtime_keymap.editor.kill_line_start.as_slice()), + ("editor", "kill_line_end") => Some(runtime_keymap.editor.kill_line_end.as_slice()), + ("editor", "yank") => Some(runtime_keymap.editor.yank.as_slice()), + ("pager", "scroll_up") => Some(runtime_keymap.pager.scroll_up.as_slice()), + ("pager", "scroll_down") => Some(runtime_keymap.pager.scroll_down.as_slice()), + ("pager", "page_up") => Some(runtime_keymap.pager.page_up.as_slice()), + ("pager", "page_down") => Some(runtime_keymap.pager.page_down.as_slice()), + ("pager", "half_page_up") => Some(runtime_keymap.pager.half_page_up.as_slice()), + ("pager", "half_page_down") => Some(runtime_keymap.pager.half_page_down.as_slice()), + ("pager", "jump_top") => Some(runtime_keymap.pager.jump_top.as_slice()), + ("pager", "jump_bottom") => Some(runtime_keymap.pager.jump_bottom.as_slice()), + ("pager", "close") => Some(runtime_keymap.pager.close.as_slice()), + ("pager", "close_transcript") => Some(runtime_keymap.pager.close_transcript.as_slice()), + ("list", "move_up") => Some(runtime_keymap.list.move_up.as_slice()), + ("list", "move_down") => Some(runtime_keymap.list.move_down.as_slice()), + ("list", "accept") => Some(runtime_keymap.list.accept.as_slice()), + ("list", "cancel") => Some(runtime_keymap.list.cancel.as_slice()), + ("approval", "open_fullscreen") => Some(runtime_keymap.approval.open_fullscreen.as_slice()), + ("approval", "open_thread") => Some(runtime_keymap.approval.open_thread.as_slice()), + ("approval", "approve") => Some(runtime_keymap.approval.approve.as_slice()), + ("approval", "approve_for_session") => Some(runtime_keymap.approval.approve_for_session.as_slice()), + ("approval", "approve_for_prefix") => Some(runtime_keymap.approval.approve_for_prefix.as_slice()), + ("approval", "deny") => Some(runtime_keymap.approval.deny.as_slice()), + ("approval", "decline") => Some(runtime_keymap.approval.decline.as_slice()), + ("approval", "cancel") => Some(runtime_keymap.approval.cancel.as_slice()), + _ => None, + } +} + +pub(super) fn format_binding_summary(bindings: &[KeyBinding]) -> String { + let mut seen = BTreeSet::new(); + let specs = bindings + .iter() + .filter_map(|binding| super::binding_to_config_key_spec(*binding).ok()) + .filter(|spec| seen.insert(spec.clone())) + .collect::>(); + if specs.is_empty() { + "unbound".to_string() + } else { + specs.join(", ") + } +} diff --git a/codex-rs/tui/src/keymap_setup/picker.rs b/codex-rs/tui/src/keymap_setup/picker.rs new file mode 100644 index 0000000000..fb3a334a42 --- /dev/null +++ b/codex-rs/tui/src/keymap_setup/picker.rs @@ -0,0 +1,386 @@ +//! Shortcut picker construction for `/keymap`. + +use codex_config::types::TuiKeymap; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use unicode_width::UnicodeWidthStr; + +use crate::app_event::AppEvent; +use crate::bottom_pane::ColumnWidthMode; +use crate::bottom_pane::SelectionItem; +use crate::bottom_pane::SelectionRowDisplay; +use crate::bottom_pane::SelectionTab; +use crate::bottom_pane::SelectionViewParams; +use crate::keymap::RuntimeKeymap; +use crate::render::renderable::ColumnRenderable; +use crate::render::renderable::Renderable; + +use super::actions::KEYMAP_ACTIONS; +use super::actions::action_label; +use super::actions::bindings_for_action; +use super::actions::format_binding_summary; +use super::has_custom_binding; + +pub(crate) const KEYMAP_PICKER_VIEW_ID: &str = "keymap-picker"; +pub(super) const KEYMAP_ALL_TAB_ID: &str = "all-shortcuts"; +pub(super) const KEYMAP_COMMON_TAB_ID: &str = "common-shortcuts"; +pub(super) const KEYMAP_CUSTOM_TAB_ID: &str = "custom-shortcuts"; +pub(super) const KEYMAP_UNBOUND_TAB_ID: &str = "unbound-shortcuts"; +const KEYMAP_CONTEXT_LABEL_WIDTH: usize = 12; +const KEYMAP_ROW_PREFIX_WIDTH: usize = KEYMAP_CONTEXT_LABEL_WIDTH + 3; + +#[derive(Clone, Debug)] +struct KeymapActionRow { + context: &'static str, + context_label: &'static str, + action: &'static str, + label: String, + description: &'static str, + binding_summary: String, + custom_binding: bool, +} + +impl KeymapActionRow { + fn is_unbound(&self) -> bool { + self.binding_summary == "unbound" + } +} + +struct KeymapContextTab { + id: &'static str, + label: &'static str, + description: &'static str, + contexts: &'static [&'static str], +} + +const KEYMAP_COMMON_ACTIONS: &[(&str, &str)] = &[ + ("composer", "submit"), + ("editor", "insert_newline"), + ("composer", "queue"), + ("global", "open_external_editor"), + ("global", "copy"), + ("editor", "delete_backward_word"), + ("editor", "delete_forward_word"), + ("editor", "move_word_left"), + ("editor", "move_word_right"), + ("global", "open_transcript"), + ("pager", "close"), + ("pager", "page_up"), + ("pager", "page_down"), + ("approval", "open_fullscreen"), + ("approval", "approve"), + ("approval", "approve_for_session"), + ("approval", "decline"), + ("approval", "cancel"), +]; + +const KEYMAP_CONTEXT_TABS: &[KeymapContextTab] = &[ + KeymapContextTab { + id: "app-shortcuts", + label: "App", + description: "Global and chat-level shortcuts.", + contexts: &["global", "chat"], + }, + KeymapContextTab { + id: "composer-shortcuts", + label: "Composer", + description: "Composer submission and queue shortcuts.", + contexts: &["composer"], + }, + KeymapContextTab { + id: "editor-shortcuts", + label: "Editor", + description: "Inline editor movement and editing shortcuts.", + contexts: &["editor"], + }, + KeymapContextTab { + id: "navigation-shortcuts", + label: "Navigation", + description: "Pager and selection-list navigation shortcuts.", + contexts: &["pager", "list"], + }, + KeymapContextTab { + id: "approval-shortcuts", + label: "Approval", + description: "Approval prompt shortcuts.", + contexts: &["approval"], + }, +]; + +pub(crate) fn build_keymap_picker_params( + runtime_keymap: &RuntimeKeymap, + keymap_config: &TuiKeymap, +) -> SelectionViewParams { + build_keymap_picker_params_for_action( + runtime_keymap, + keymap_config, + /*selected_action*/ None, + ) +} + +pub(crate) fn build_keymap_picker_params_for_selected_action( + runtime_keymap: &RuntimeKeymap, + keymap_config: &TuiKeymap, + context: &str, + action: &str, +) -> SelectionViewParams { + build_keymap_picker_params_for_action(runtime_keymap, keymap_config, Some((context, action))) +} + +fn build_keymap_picker_params_for_action( + runtime_keymap: &RuntimeKeymap, + keymap_config: &TuiKeymap, + selected_action: Option<(&str, &str)>, +) -> SelectionViewParams { + let rows = build_keymap_rows(runtime_keymap, keymap_config); + let total = rows.len(); + let custom_count = rows.iter().filter(|row| row.custom_binding).count(); + let unbound_count = rows.iter().filter(|row| row.is_unbound()).count(); + let initial_selected_idx = selected_action.and_then(|(context, action)| { + rows.iter() + .position(|row| row.context == context && row.action == action) + }); + let name_column_width = rows + .iter() + .map(|row| KEYMAP_ROW_PREFIX_WIDTH + UnicodeWidthStr::width(row.label.as_str())) + .max(); + + let mut tabs = Vec::new(); + tabs.push(SelectionTab { + id: KEYMAP_ALL_TAB_ID.to_string(), + label: "All".to_string(), + header: keymap_header( + "All configurable shortcuts.".to_string(), + format!("{total} actions, {custom_count} customized, {unbound_count} unbound."), + ), + items: keymap_selection_items( + rows.iter(), + "No shortcuts available", + "No configurable shortcuts are available.", + ), + }); + + let common_rows = keymap_common_rows(&rows); + let common_count = common_rows.len(); + tabs.push(SelectionTab { + id: KEYMAP_COMMON_TAB_ID.to_string(), + label: "Common".to_string(), + header: keymap_header( + "Frequently customized shortcuts.".to_string(), + action_count_line(common_count), + ), + items: keymap_selection_items( + common_rows, + "No common shortcuts", + "No common shortcut actions are available.", + ), + }); + + let custom_rows = rows + .iter() + .filter(|row| row.custom_binding) + .collect::>(); + tabs.push(SelectionTab { + id: KEYMAP_CUSTOM_TAB_ID.to_string(), + label: format!("Customized ({custom_count})"), + header: keymap_header( + "Root-level shortcut overrides.".to_string(), + action_count_line(custom_count), + ), + items: keymap_selection_items( + custom_rows, + "No customized shortcuts", + "No root-level keymap overrides have been configured.", + ), + }); + + let unbound_rows = rows + .iter() + .filter(|row| row.is_unbound()) + .collect::>(); + tabs.push(SelectionTab { + id: KEYMAP_UNBOUND_TAB_ID.to_string(), + label: format!("Unbound ({unbound_count})"), + header: keymap_header( + "Actions without an active shortcut.".to_string(), + action_count_line(unbound_count), + ), + items: keymap_selection_items( + unbound_rows, + "No unbound shortcuts", + "Every configurable action currently has a shortcut.", + ), + }); + + for tab in KEYMAP_CONTEXT_TABS { + let tab_rows = rows + .iter() + .filter(|row| tab.contexts.contains(&row.context)) + .collect::>(); + let count = tab_rows.len(); + tabs.push(SelectionTab { + id: tab.id.to_string(), + label: tab.label.to_string(), + header: keymap_header(tab.description.to_string(), action_count_line(count)), + items: keymap_selection_items( + tab_rows, + "No shortcuts in this group", + "No configurable actions are available in this group.", + ), + }); + } + + SelectionViewParams { + view_id: Some(KEYMAP_PICKER_VIEW_ID), + header: Box::new(()), + footer_hint: Some(keymap_picker_hint_line()), + tabs, + initial_tab_id: Some(KEYMAP_ALL_TAB_ID.to_string()), + is_searchable: true, + search_placeholder: Some("Type to search shortcuts".to_string()), + col_width_mode: ColumnWidthMode::AutoAllRows, + row_display: SelectionRowDisplay::SingleLine, + name_column_width, + initial_selected_idx, + ..Default::default() + } +} + +fn build_keymap_rows( + runtime_keymap: &RuntimeKeymap, + keymap_config: &TuiKeymap, +) -> Vec { + KEYMAP_ACTIONS + .iter() + .map(|descriptor| { + let bindings = + bindings_for_action(runtime_keymap, descriptor.context, descriptor.action) + .unwrap_or(&[]); + KeymapActionRow { + context: descriptor.context, + context_label: descriptor.context_label, + action: descriptor.action, + label: action_label(descriptor.action), + description: descriptor.description, + binding_summary: format_binding_summary(bindings), + custom_binding: has_custom_binding( + keymap_config, + descriptor.context, + descriptor.action, + ) + .unwrap_or(false), + } + }) + .collect() +} + +fn keymap_common_rows(rows: &[KeymapActionRow]) -> Vec<&KeymapActionRow> { + KEYMAP_COMMON_ACTIONS + .iter() + .filter_map(|(context, action)| { + rows.iter() + .find(|row| row.context == *context && row.action == *action) + }) + .collect() +} + +fn keymap_selection_items<'a>( + rows: impl IntoIterator, + empty_name: &str, + empty_description: &str, +) -> Vec { + let items = rows + .into_iter() + .map(keymap_selection_item) + .collect::>(); + if items.is_empty() { + return vec![SelectionItem { + name: empty_name.to_string(), + description: Some(empty_description.to_string()), + is_disabled: true, + ..Default::default() + }]; + } + + items +} + +fn keymap_selection_item(row: &KeymapActionRow) -> SelectionItem { + let context = row.context.to_string(); + let action = row.action.to_string(); + let source = if row.custom_binding { + "Custom" + } else { + "Default" + }; + let search_value = format!( + "{} {} {} {} {} {}", + row.context_label, row.action, row.label, row.description, row.binding_summary, source + ); + + SelectionItem { + name: row.label.clone(), + name_prefix_spans: keymap_row_prefix(row), + description: Some(row.binding_summary.clone()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenKeymapActionMenu { + context: context.clone(), + action: action.clone(), + }); + })], + search_value: Some(search_value), + ..Default::default() + } +} + +fn keymap_row_prefix(row: &KeymapActionRow) -> Vec> { + let indicator = if row.custom_binding { + "*".cyan() + } else if row.is_unbound() { + "-".dim() + } else { + " ".into() + }; + + vec![ + format!( + "{: Box { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Keymap".bold())); + header.push(Line::from(description.dim())); + header.push(Line::from(summary.dim())); + Box::new(header) +} + +fn action_count_line(count: usize) -> String { + match count { + 1 => "1 action.".to_string(), + _ => format!("{count} actions."), + } +} + +fn keymap_picker_hint_line() -> Line<'static> { + Line::from(vec![ + "left/right".cyan(), + " group · ".dim(), + "enter".cyan(), + " edit shortcut · ".dim(), + "*".cyan(), + " custom · ".dim(), + "-".cyan(), + " unbound · ".dim(), + "esc".cyan(), + " close".dim(), + ]) +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 6ecd01b76a..b0bfe7c30d 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -131,6 +131,8 @@ mod history_cell; pub(crate) mod insert_history; pub use insert_history::insert_history_lines; mod key_hint; +mod keymap; +mod keymap_setup; mod line_truncation; pub(crate) mod live_wrap; pub use live_wrap::RowBuilder; diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index cbe1a1fe8b..4698fabd92 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -1,3 +1,10 @@ +//! Authentication step UI and state transitions used by onboarding. +//! +//! This module owns the auth-step state machine (ChatGPT login/device-code/API +//! key), renders the corresponding UI, and handles auth-scoped keyboard input. +//! It intentionally does not decide onboarding flow completion; the enclosing +//! onboarding screen coordinates step progression. + #![allow(clippy::unwrap_used)] use codex_app_server_client::AppServerRequestHandle; @@ -37,6 +44,9 @@ use std::sync::RwLock; use uuid::Uuid; use crate::LoginStatus; +use crate::key_hint::KeyBinding; +use crate::key_hint::KeyBindingListExt; +use crate::onboarding::keys; use crate::onboarding::onboarding_screen::KeyboardHandler; use crate::onboarding::onboarding_screen::StepStateProvider; use crate::shimmer::shimmer_spans; @@ -185,39 +195,42 @@ impl KeyboardHandler for AuthModeWidget { return; } - match key_event.code { - KeyCode::Up | KeyCode::Char('k') => { - self.move_highlight(/*delta*/ -1); - } - KeyCode::Down | KeyCode::Char('j') => { - self.move_highlight(/*delta*/ 1); - } - KeyCode::Char('1') => { - self.select_option_by_index(/*index*/ 0); - } - KeyCode::Char('2') => { - self.select_option_by_index(/*index*/ 1); - } - KeyCode::Char('3') => { - self.select_option_by_index(/*index*/ 2); - } - KeyCode::Enter => { - let sign_in_state = { (*self.sign_in_state.read().unwrap()).clone() }; - match sign_in_state { - SignInState::PickMode => { - self.handle_sign_in_option(self.highlighted_mode); - } - SignInState::ChatGptSuccessMessage => { - *self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess; - } - _ => {} + if keys::MOVE_UP.is_pressed(key_event) { + self.move_highlight(/*delta*/ -1); + return; + } + if keys::MOVE_DOWN.is_pressed(key_event) { + self.move_highlight(/*delta*/ 1); + return; + } + if keys::SELECT_FIRST.is_pressed(key_event) { + self.select_option_by_index(/*index*/ 0); + return; + } + if keys::SELECT_SECOND.is_pressed(key_event) { + self.select_option_by_index(/*index*/ 1); + return; + } + if keys::SELECT_THIRD.is_pressed(key_event) { + self.select_option_by_index(/*index*/ 2); + return; + } + if keys::CONFIRM.is_pressed(key_event) { + let sign_in_state = { (*self.sign_in_state.read().unwrap()).clone() }; + match sign_in_state { + SignInState::PickMode => { + self.handle_sign_in_option(self.highlighted_mode); } + SignInState::ChatGptSuccessMessage => { + *self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess; + } + _ => {} } - KeyCode::Esc => { - tracing::info!("Esc pressed"); - self.cancel_active_attempt(); - } - _ => {} + return; + } + if keys::CANCEL.is_pressed(key_event) { + tracing::info!("Cancel onboarding auth step"); + self.cancel_active_attempt(); } } @@ -286,6 +299,28 @@ impl AuthModeWidget { self.error.read().unwrap().clone() } + /// Returns whether the auth flow is currently in API-key entry mode. + pub(crate) fn is_api_key_entry_active(&self) -> bool { + self.sign_in_state + .read() + .is_ok_and(|guard| matches!(&*guard, SignInState::ApiKeyEntry(_))) + } + + /// Returns whether the API-key entry field currently contains any text. + pub(crate) fn api_key_entry_has_text(&self) -> bool { + self.sign_in_state.read().is_ok_and( + |guard| matches!(&*guard, SignInState::ApiKeyEntry(state) if !state.value.is_empty()), + ) + } + + fn confirm_binding(&self) -> KeyBinding { + keys::CONFIRM[0] + } + + fn cancel_binding(&self) -> KeyBinding { + keys::CANCEL[0] + } + fn is_api_login_allowed(&self) -> bool { !matches!(self.forced_login_method, Some(ForcedLoginMethod::Chatgpt)) } @@ -455,11 +490,11 @@ impl AuthModeWidget { ); lines.push("".into()); } - lines.push( - // AE: Following styles.md, this should probably be Cyan because it's a user input tip. - // But leaving this for a future cleanup. - " Press Enter to continue".dim().into(), - ); + lines.push(Line::from(vec![ + " Press ".dim(), + self.confirm_binding().into(), + " to continue".dim(), + ])); if let Some(err) = self.error_message() { lines.push("".into()); lines.push(err.red().into()); @@ -494,7 +529,9 @@ impl AuthModeWidget { ])); lines.push("".into()); lines.push(Line::from(vec![ - " On a remote or headless machine? Press Esc and choose ".into(), + " On a remote or headless machine? Press ".into(), + self.cancel_binding().into(), + " and choose ".into(), "Sign in with Device Code".cyan(), ".".into(), ])); @@ -504,7 +541,11 @@ impl AuthModeWidget { None }; - lines.push(" Press Esc to cancel".dim().into()); + lines.push(Line::from(vec![ + " Press ".dim(), + self.cancel_binding().into(), + " to cancel".dim(), + ])); Paragraph::new(lines) .wrap(Wrap { trim: false }) .render(area, buf); @@ -539,7 +580,11 @@ impl AuthModeWidget { ]) .dim(), "".into(), - " Press Enter to continue".fg(Color::Cyan).into(), + Line::from(vec![ + " Press ".fg(Color::Cyan), + self.confirm_binding().into(), + " to continue".fg(Color::Cyan), + ]), ]; Paragraph::new(lines) @@ -618,8 +663,16 @@ impl AuthModeWidget { .render(input_area, buf); let mut footer_lines: Vec = vec![ - " Press Enter to save".dim().into(), - " Press Esc to go back".dim().into(), + Line::from(vec![ + " Press ".dim(), + self.confirm_binding().into(), + " to save".dim(), + ]), + Line::from(vec![ + " Press ".dim(), + self.cancel_binding().into(), + " to go back".dim(), + ]), ]; if let Some(error) = self.error_message() { footer_lines.push("".into()); @@ -637,46 +690,46 @@ impl AuthModeWidget { { let mut guard = self.sign_in_state.write().unwrap(); if let SignInState::ApiKeyEntry(state) = &mut *guard { - match key_event.code { - KeyCode::Esc => { - *guard = SignInState::PickMode; - self.set_error(/*message*/ None); + if keys::CANCEL.is_pressed(*key_event) { + *guard = SignInState::PickMode; + self.set_error(/*message*/ None); + should_request_frame = true; + } else if keys::CONFIRM.is_pressed(*key_event) { + let trimmed = state.value.trim().to_string(); + if trimmed.is_empty() { + self.set_error(Some("API key cannot be empty".to_string())); should_request_frame = true; + } else { + should_save = Some(trimmed); } - KeyCode::Enter => { - let trimmed = state.value.trim().to_string(); - if trimmed.is_empty() { - self.set_error(Some("API key cannot be empty".to_string())); + } else { + match key_event.code { + KeyCode::Backspace => { + if state.prepopulated_from_env { + state.value.clear(); + state.prepopulated_from_env = false; + } else { + state.value.pop(); + } + self.set_error(/*message*/ None); should_request_frame = true; - } else { - should_save = Some(trimmed); } - } - KeyCode::Backspace => { - if state.prepopulated_from_env { - state.value.clear(); - state.prepopulated_from_env = false; - } else { - state.value.pop(); + KeyCode::Char(c) + if key_event.kind == KeyEventKind::Press + && !key_event.modifiers.contains(KeyModifiers::SUPER) + && !key_event.modifiers.contains(KeyModifiers::CONTROL) + && !key_event.modifiers.contains(KeyModifiers::ALT) => + { + if state.prepopulated_from_env { + state.value.clear(); + state.prepopulated_from_env = false; + } + state.value.push(c); + self.set_error(/*message*/ None); + should_request_frame = true; } - self.set_error(/*message*/ None); - should_request_frame = true; + _ => {} } - KeyCode::Char(c) - if key_event.kind == KeyEventKind::Press - && !key_event.modifiers.contains(KeyModifiers::SUPER) - && !key_event.modifiers.contains(KeyModifiers::CONTROL) - && !key_event.modifiers.contains(KeyModifiers::ALT) => - { - if state.prepopulated_from_env { - state.value.clear(); - state.prepopulated_from_env = false; - } - state.value.push(c); - self.set_error(/*message*/ None); - should_request_frame = true; - } - _ => {} } // handled; let guard drop before potential save } else { diff --git a/codex-rs/tui/src/onboarding/auth/headless_chatgpt_login.rs b/codex-rs/tui/src/onboarding/auth/headless_chatgpt_login.rs index a3e82e6cd8..2282649fd0 100644 --- a/codex-rs/tui/src/onboarding/auth/headless_chatgpt_login.rs +++ b/codex-rs/tui/src/onboarding/auth/headless_chatgpt_login.rs @@ -137,7 +137,11 @@ pub(super) fn render_device_code_login( None }; - lines.push(" Press Esc to cancel".dim().into()); + lines.push(Line::from(vec![ + " Press ".dim(), + widget.cancel_binding().into(), + " to cancel".dim(), + ])); Paragraph::new(lines) .wrap(Wrap { trim: false }) .render(area, buf); diff --git a/codex-rs/tui/src/onboarding/keys.rs b/codex-rs/tui/src/onboarding/keys.rs new file mode 100644 index 0000000000..5d782a553f --- /dev/null +++ b/codex-rs/tui/src/onboarding/keys.rs @@ -0,0 +1,39 @@ +//! Fixed shortcuts used before users have had a chance to configure Codex. + +use crossterm::event::KeyCode; +use crossterm::event::KeyModifiers; + +use crate::key_hint; +use crate::key_hint::KeyBinding; + +pub(crate) const MOVE_UP: [KeyBinding; 2] = [ + key_hint::plain(KeyCode::Up), + key_hint::plain(KeyCode::Char('k')), +]; +pub(crate) const MOVE_DOWN: [KeyBinding; 2] = [ + key_hint::plain(KeyCode::Down), + key_hint::plain(KeyCode::Char('j')), +]; +pub(crate) const SELECT_FIRST: [KeyBinding; 2] = [ + key_hint::plain(KeyCode::Char('1')), + key_hint::plain(KeyCode::Char('y')), +]; +pub(crate) const SELECT_SECOND: [KeyBinding; 2] = [ + key_hint::plain(KeyCode::Char('2')), + key_hint::plain(KeyCode::Char('n')), +]; +pub(crate) const SELECT_THIRD: [KeyBinding; 1] = [key_hint::plain(KeyCode::Char('3'))]; +pub(crate) const CONFIRM: [KeyBinding; 1] = [key_hint::plain(KeyCode::Enter)]; +pub(crate) const CANCEL: [KeyBinding; 1] = [key_hint::plain(KeyCode::Esc)]; +pub(crate) const QUIT: [KeyBinding; 3] = [ + key_hint::plain(KeyCode::Char('q')), + key_hint::ctrl(KeyCode::Char('c')), + key_hint::ctrl(KeyCode::Char('d')), +]; +pub(crate) const TOGGLE_ANIMATION: [KeyBinding; 2] = [ + key_hint::ctrl(KeyCode::Char('.')), + KeyBinding::new( + KeyCode::Char('.'), + KeyModifiers::CONTROL.union(KeyModifiers::SHIFT), + ), +]; diff --git a/codex-rs/tui/src/onboarding/mod.rs b/codex-rs/tui/src/onboarding/mod.rs index 0db5204d31..63ebdc6926 100644 --- a/codex-rs/tui/src/onboarding/mod.rs +++ b/codex-rs/tui/src/onboarding/mod.rs @@ -1,4 +1,5 @@ mod auth; +mod keys; pub(crate) mod onboarding_screen; mod trust_directory; pub(crate) use auth::mark_url_hyperlink; diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index 0a6e8a3d7a..6edb6a6d86 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -1,6 +1,15 @@ -use crate::legacy_core::config::Config; -#[cfg(target_os = "windows")] -use crate::legacy_core::windows_sandbox::WindowsSandboxLevelExt; +//! Onboarding screen orchestration and top-level keyboard routing. +//! +//! The onboarding flow is a small state machine over visible steps +//! (welcome/auth/trust). This module decides which step receives key/paste +//! events and enforces flow-level safety rules that cut across individual step +//! widgets. +//! +//! In particular, onboarding quit handling has a text-entry guard for API-key +//! input: the printable `q` quit key is treated as text input while the user is +//! editing a non-empty API-key field, while control/alt chords remain available +//! as explicit exit shortcuts. + use codex_app_server_client::AppServerEvent; use codex_app_server_client::AppServerRequestHandle; use codex_app_server_protocol::ServerNotification; @@ -11,6 +20,7 @@ use codex_protocol::config_types::WindowsSandboxLevel; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::prelude::Widget; @@ -22,9 +32,14 @@ use codex_protocol::config_types::ForcedLoginMethod; use crate::LoginStatus; use crate::app_server_session::AppServerSession; +use crate::key_hint::KeyBindingListExt; +use crate::legacy_core::config::Config; +#[cfg(target_os = "windows")] +use crate::legacy_core::windows_sandbox::WindowsSandboxLevelExt; use crate::onboarding::auth::AuthModeWidget; use crate::onboarding::auth::SignInOption; use crate::onboarding::auth::SignInState; +use crate::onboarding::keys; use crate::onboarding::trust_directory::TrustDirectorySelection; use crate::onboarding::trust_directory::TrustDirectoryWidget; use crate::onboarding::welcome::WelcomeWidget; @@ -78,6 +93,14 @@ pub(crate) struct OnboardingResult { pub should_exit: bool, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +struct ApiKeyEntryContext { + /// True when onboarding is currently rendering the API-key entry state. + active: bool, + /// True when the API-key input field currently contains user text. + has_text: bool, +} + impl OnboardingScreen { pub(crate) async fn new(tui: &mut Tui, args: OnboardingScreenArgs) -> Self { let OnboardingScreenArgs { @@ -248,45 +271,39 @@ impl OnboardingScreen { } } - fn is_api_key_entry_active(&self) -> bool { - self.steps.iter().any(|step| { - if let Step::Auth(widget) = step { - return widget - .sign_in_state - .read() - .is_ok_and(|g| matches!(&*g, SignInState::ApiKeyEntry(_))); - } - false - }) + fn api_key_entry_context(&self) -> ApiKeyEntryContext { + self.steps + .iter() + .find_map(|step| { + if let Step::Auth(widget) = step { + Some(ApiKeyEntryContext { + active: widget.is_api_key_entry_active(), + has_text: widget.api_key_entry_has_text(), + }) + } else { + None + } + }) + .unwrap_or_default() } } impl KeyboardHandler for OnboardingScreen { + /// Route key events to onboarding steps while preserving text-entry safety. + /// + /// In API-key entry mode, printable quit bindings are suppressed only after + /// the user has started typing in the API-key field. This keeps the + /// printable `q` quit key usable on an empty field while protecting in-progress + /// text entry from accidental exits. Control/alt quit chords still work as + /// emergency exits. fn handle_key_event(&mut self, key_event: KeyEvent) { if !matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) { return; } - let is_api_key_entry_active = self.is_api_key_entry_active(); - let should_quit = match key_event { - KeyEvent { - code: KeyCode::Char('d'), - modifiers: crossterm::event::KeyModifiers::CONTROL, - kind: KeyEventKind::Press, - .. - } - | KeyEvent { - code: KeyCode::Char('c'), - modifiers: crossterm::event::KeyModifiers::CONTROL, - kind: KeyEventKind::Press, - .. - } => true, - KeyEvent { - code: KeyCode::Char('q'), - kind: KeyEventKind::Press, - .. - } => !is_api_key_entry_active, - _ => false, - }; + let api_key_entry_context = self.api_key_entry_context(); + let should_quit = key_event.kind == KeyEventKind::Press + && keys::QUIT.is_pressed(key_event) + && !suppress_quit_while_typing_api_key(key_event, api_key_entry_context); if should_quit { if self.is_auth_in_progress() { self.cancel_auth_if_active(); @@ -332,6 +349,24 @@ impl KeyboardHandler for OnboardingScreen { } } +/// Returns `true` when a quit shortcut should be ignored as text input. +/// +/// This only applies while API-key entry is active and the key is a printable +/// character without control/alt modifiers and there is already text in the +/// input field. Empty input intentionally does not trigger suppression so +/// the printable `q` quit key can still exit onboarding. +fn suppress_quit_while_typing_api_key( + key_event: KeyEvent, + api_key_entry_context: ApiKeyEntryContext, +) -> bool { + api_key_entry_context.active + && api_key_entry_context.has_text + && matches!(key_event.code, KeyCode::Char(_)) + && !key_event + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) +} + impl WidgetRef for &OnboardingScreen { fn render_ref(&self, area: Rect, buf: &mut Buffer) { let suppress_animations = self.should_suppress_animations(); @@ -544,3 +579,60 @@ pub(crate) async fn run_onboarding_app( should_exit: onboarding_screen.should_exit(), }) } + +#[cfg(test)] +mod tests { + use super::ApiKeyEntryContext; + use super::suppress_quit_while_typing_api_key; + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + #[test] + fn suppresses_printable_quit_key_during_api_key_entry() { + let suppressed = suppress_quit_while_typing_api_key( + KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE), + ApiKeyEntryContext { + active: true, + has_text: true, + }, + ); + assert!(suppressed); + } + + #[test] + fn does_not_suppress_printable_quit_key_when_api_key_input_is_empty() { + let suppressed = suppress_quit_while_typing_api_key( + KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE), + ApiKeyEntryContext { + active: true, + has_text: false, + }, + ); + assert!(!suppressed); + } + + #[test] + fn does_not_suppress_control_quit_key_during_api_key_entry() { + let suppressed = suppress_quit_while_typing_api_key( + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL), + ApiKeyEntryContext { + active: true, + has_text: true, + }, + ); + assert!(!suppressed); + } + + #[test] + fn does_not_suppress_when_not_in_api_key_entry() { + let suppressed = suppress_quit_while_typing_api_key( + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), + ApiKeyEntryContext { + active: false, + has_text: true, + }, + ); + assert!(!suppressed); + } +} diff --git a/codex-rs/tui/src/onboarding/trust_directory.rs b/codex-rs/tui/src/onboarding/trust_directory.rs index 5a1626fda4..ffc0c040a9 100644 --- a/codex-rs/tui/src/onboarding/trust_directory.rs +++ b/codex-rs/tui/src/onboarding/trust_directory.rs @@ -2,7 +2,6 @@ use std::path::PathBuf; use crate::legacy_core::config::set_project_trust_level; use codex_protocol::config_types::TrustLevel; -use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; use ratatui::buffer::Buffer; @@ -13,7 +12,8 @@ use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; -use crate::key_hint; +use crate::key_hint::KeyBindingListExt; +use crate::onboarding::keys; use crate::onboarding::onboarding_screen::KeyboardHandler; use crate::onboarding::onboarding_screen::StepStateProvider; use crate::render::Insets; @@ -112,7 +112,7 @@ impl WidgetRef for &TrustDirectoryWidget { column.push( Line::from(vec![ "Press ".dim(), - key_hint::plain(KeyCode::Enter).into(), + keys::CONFIRM[0].into(), if self.show_windows_create_sandbox_hint { " to continue and create a sandbox...".dim() } else { @@ -134,20 +134,22 @@ impl KeyboardHandler for TrustDirectoryWidget { return; } - match key_event.code { - KeyCode::Up | KeyCode::Char('k') => { - self.highlighted = TrustDirectorySelection::Trust; - } - KeyCode::Down | KeyCode::Char('j') => { - self.highlighted = TrustDirectorySelection::Quit; - } - KeyCode::Char('1') | KeyCode::Char('y') => self.handle_trust(), - KeyCode::Char('2') | KeyCode::Char('n') => self.handle_quit(), - KeyCode::Enter => match self.highlighted { + if keys::MOVE_UP.is_pressed(key_event) { + self.highlighted = TrustDirectorySelection::Trust; + } else if keys::MOVE_DOWN.is_pressed(key_event) { + self.highlighted = TrustDirectorySelection::Quit; + } else if keys::SELECT_FIRST.is_pressed(key_event) { + self.handle_trust(); + } else if keys::SELECT_SECOND.is_pressed(key_event) + || keys::QUIT.is_pressed(key_event) + || keys::CANCEL.is_pressed(key_event) + { + self.handle_quit(); + } else if keys::CONFIRM.is_pressed(key_event) { + match self.highlighted { TrustDirectorySelection::Trust => self.handle_trust(), TrustDirectorySelection::Quit => self.handle_quit(), - }, - _ => {} + } } } } diff --git a/codex-rs/tui/src/onboarding/welcome.rs b/codex-rs/tui/src/onboarding/welcome.rs index acb12abaab..369d1f6aeb 100644 --- a/codex-rs/tui/src/onboarding/welcome.rs +++ b/codex-rs/tui/src/onboarding/welcome.rs @@ -1,7 +1,5 @@ -use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; -use crossterm::event::KeyModifiers; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::prelude::Widget; @@ -14,6 +12,8 @@ use ratatui::widgets::Wrap; use std::cell::Cell; use crate::ascii_animation::AsciiAnimation; +use crate::key_hint::KeyBindingListExt; +use crate::onboarding::keys; use crate::onboarding::onboarding_screen::KeyboardHandler; use crate::onboarding::onboarding_screen::StepStateProvider; use crate::tui::FrameRequester; @@ -32,14 +32,15 @@ pub(crate) struct WelcomeWidget { } impl KeyboardHandler for WelcomeWidget { + /// Rotate the welcome animation when the fixed toggle shortcut fires. + /// + /// The key list includes compatibility variants for terminals that report + /// modifier bits differently. fn handle_key_event(&mut self, key_event: KeyEvent) { if !self.animations_enabled { return; } - if key_event.kind == KeyEventKind::Press - && key_event.code == KeyCode::Char('.') - && key_event.modifiers.contains(KeyModifiers::CONTROL) - { + if key_event.kind == KeyEventKind::Press && keys::TOGGLE_ANIMATION.is_pressed(key_event) { tracing::warn!("Welcome background to press '.'"); let _ = self.animation.pick_random_variant(); } @@ -115,6 +116,8 @@ impl StepStateProvider for WelcomeWidget { #[cfg(test)] mod tests { use super::*; + use crossterm::event::KeyCode; + use crossterm::event::KeyModifiers; use pretty_assertions::assert_eq; use ratatui::buffer::Buffer; use ratatui::layout::Rect; @@ -187,4 +190,31 @@ mod tests { "expected ctrl+. to switch welcome animation variant" ); } + + #[test] + fn ctrl_shift_dot_changes_animation_variant() { + let mut widget = WelcomeWidget { + is_logged_in: false, + animation: AsciiAnimation::with_variants( + FrameRequester::test_dummy(), + &VARIANTS, + /*variant_idx*/ 0, + ), + animations_enabled: true, + animations_suppressed: Cell::new(false), + layout_area: Cell::new(None), + }; + + let before = widget.animation.current_frame(); + widget.handle_key_event(KeyEvent::new( + KeyCode::Char('.'), + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + )); + let after = widget.animation.current_frame(); + + assert_ne!( + before, after, + "expected ctrl+shift+. to switch welcome animation variant" + ); + } } diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index 9fe0e3916e..7878ea8551 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -23,6 +23,8 @@ use crate::history_cell::HistoryCell; use crate::history_cell::UserHistoryCell; use crate::key_hint; use crate::key_hint::KeyBinding; +use crate::key_hint::KeyBindingListExt; +use crate::keymap::PagerKeymap; use crate::render::Insets; use crate::render::renderable::InsetRenderable; use crate::render::renderable::Renderable; @@ -51,19 +53,24 @@ pub(crate) enum Overlay { } impl Overlay { - pub(crate) fn new_transcript(cells: Vec>) -> Self { - Self::Transcript(TranscriptOverlay::new(cells)) + pub(crate) fn new_transcript(cells: Vec>, keymap: PagerKeymap) -> Self { + Self::Transcript(TranscriptOverlay::new(cells, keymap)) } - pub(crate) fn new_static_with_lines(lines: Vec>, title: String) -> Self { - Self::Static(StaticOverlay::with_title(lines, title)) + pub(crate) fn new_static_with_lines( + lines: Vec>, + title: String, + keymap: PagerKeymap, + ) -> Self { + Self::Static(StaticOverlay::with_title(lines, title, keymap)) } pub(crate) fn new_static_with_renderables( renderables: Vec>, title: String, + keymap: PagerKeymap, ) -> Self { - Self::Static(StaticOverlay::with_renderables(renderables, title)) + Self::Static(StaticOverlay::with_renderables(renderables, title, keymap)) } pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> { @@ -81,37 +88,12 @@ impl Overlay { } } -const KEY_UP: KeyBinding = key_hint::plain(KeyCode::Up); -const KEY_DOWN: KeyBinding = key_hint::plain(KeyCode::Down); -const KEY_K: KeyBinding = key_hint::plain(KeyCode::Char('k')); -const KEY_J: KeyBinding = key_hint::plain(KeyCode::Char('j')); -const KEY_PAGE_UP: KeyBinding = key_hint::plain(KeyCode::PageUp); -const KEY_PAGE_DOWN: KeyBinding = key_hint::plain(KeyCode::PageDown); -const KEY_SPACE: KeyBinding = key_hint::plain(KeyCode::Char(' ')); -const KEY_SHIFT_SPACE: KeyBinding = key_hint::shift(KeyCode::Char(' ')); -const KEY_HOME: KeyBinding = key_hint::plain(KeyCode::Home); -const KEY_END: KeyBinding = key_hint::plain(KeyCode::End); -const KEY_LEFT: KeyBinding = key_hint::plain(KeyCode::Left); -const KEY_RIGHT: KeyBinding = key_hint::plain(KeyCode::Right); -const KEY_CTRL_F: KeyBinding = key_hint::ctrl(KeyCode::Char('f')); -const KEY_CTRL_D: KeyBinding = key_hint::ctrl(KeyCode::Char('d')); -const KEY_CTRL_B: KeyBinding = key_hint::ctrl(KeyCode::Char('b')); -const KEY_CTRL_U: KeyBinding = key_hint::ctrl(KeyCode::Char('u')); -const KEY_Q: KeyBinding = key_hint::plain(KeyCode::Char('q')); -const KEY_ESC: KeyBinding = key_hint::plain(KeyCode::Esc); -const KEY_ENTER: KeyBinding = key_hint::plain(KeyCode::Enter); -const KEY_CTRL_T: KeyBinding = key_hint::ctrl(KeyCode::Char('t')); -const KEY_CTRL_C: KeyBinding = key_hint::ctrl(KeyCode::Char('c')); - -// Common pager navigation hints rendered on the first line -const PAGER_KEY_HINTS: &[(&[KeyBinding], &str)] = &[ - (&[KEY_UP, KEY_DOWN], "to scroll"), - (&[KEY_PAGE_UP, KEY_PAGE_DOWN], "to page"), - (&[KEY_HOME, KEY_END], "to jump"), -]; +fn first_or_empty(bindings: &[KeyBinding]) -> Vec { + bindings.first().copied().into_iter().collect() +} // Render a single line of key hints from (key(s), description) pairs. -fn render_key_hints(area: Rect, buf: &mut Buffer, pairs: &[(&[KeyBinding], &str)]) { +fn render_key_hints(area: Rect, buf: &mut Buffer, pairs: &[(Vec, &str)]) { let mut spans: Vec> = vec![" ".into()]; let mut first = true; for (keys, desc) in pairs { @@ -136,6 +118,7 @@ struct PagerView { renderables: Vec>, scroll_offset: usize, title: String, + keymap: PagerKeymap, last_content_height: Option, last_rendered_height: Option, /// If set, on next render ensure this chunk is visible. @@ -143,11 +126,17 @@ struct PagerView { } impl PagerView { - fn new(renderables: Vec>, title: String, scroll_offset: usize) -> Self { + fn new( + renderables: Vec>, + title: String, + scroll_offset: usize, + keymap: PagerKeymap, + ) -> Self { Self { renderables, scroll_offset, title, + keymap, last_content_height: None, last_rendered_height: None, pending_scroll_chunk: None, @@ -260,37 +249,34 @@ impl PagerView { fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) -> Result<()> { match key_event { - e if KEY_UP.is_press(e) || KEY_K.is_press(e) => { + e if self.keymap.scroll_up.is_pressed(e) => { self.scroll_offset = self.scroll_offset.saturating_sub(1); } - e if KEY_DOWN.is_press(e) || KEY_J.is_press(e) => { + e if self.keymap.scroll_down.is_pressed(e) => { self.scroll_offset = self.scroll_offset.saturating_add(1); } - e if KEY_PAGE_UP.is_press(e) - || KEY_SHIFT_SPACE.is_press(e) - || KEY_CTRL_B.is_press(e) => - { + e if self.keymap.page_up.is_pressed(e) => { let page_height = self.page_height(tui.terminal.viewport_area); self.scroll_offset = self.scroll_offset.saturating_sub(page_height); } - e if KEY_PAGE_DOWN.is_press(e) || KEY_SPACE.is_press(e) || KEY_CTRL_F.is_press(e) => { + e if self.keymap.page_down.is_pressed(e) => { let page_height = self.page_height(tui.terminal.viewport_area); self.scroll_offset = self.scroll_offset.saturating_add(page_height); } - e if KEY_CTRL_D.is_press(e) => { + e if self.keymap.half_page_down.is_pressed(e) => { let area = self.content_area(tui.terminal.viewport_area); let half_page = (area.height as usize).saturating_add(1) / 2; self.scroll_offset = self.scroll_offset.saturating_add(half_page); } - e if KEY_CTRL_U.is_press(e) => { + e if self.keymap.half_page_up.is_pressed(e) => { let area = self.content_area(tui.terminal.viewport_area); let half_page = (area.height as usize).saturating_add(1) / 2; self.scroll_offset = self.scroll_offset.saturating_sub(half_page); } - e if KEY_HOME.is_press(e) => { + e if self.keymap.jump_top.is_pressed(e) => { self.scroll_offset = 0; } - e if KEY_END.is_press(e) => { + e if self.keymap.jump_bottom.is_pressed(e) => { self.scroll_offset = usize::MAX; } _ => { @@ -454,12 +440,13 @@ impl TranscriptOverlay { /// /// This overlay does not own the "active cell"; callers may optionally append a live tail via /// `sync_live_tail` during draws to reflect in-flight activity. - pub(crate) fn new(transcript_cells: Vec>) -> Self { + pub(crate) fn new(transcript_cells: Vec>, keymap: PagerKeymap) -> Self { Self { view: PagerView::new( Self::render_cells(&transcript_cells, /*highlight_cell*/ None), "T R A N S C R I P T".to_string(), usize::MAX, + keymap, ), cells: transcript_cells, highlight_cell: None, @@ -711,15 +698,48 @@ impl TranscriptOverlay { fn render_hints(&self, area: Rect, buf: &mut Buffer) { let line1 = Rect::new(area.x, area.y, area.width, 1); let line2 = Rect::new(area.x, area.y.saturating_add(1), area.width, 1); - render_key_hints(line1, buf, PAGER_KEY_HINTS); + render_key_hints( + line1, + buf, + &[ + ( + first_or_empty(&self.view.keymap.scroll_up) + .into_iter() + .chain(first_or_empty(&self.view.keymap.scroll_down)) + .collect(), + "to scroll", + ), + ( + first_or_empty(&self.view.keymap.page_up) + .into_iter() + .chain(first_or_empty(&self.view.keymap.page_down)) + .collect(), + "to page", + ), + ( + first_or_empty(&self.view.keymap.jump_top) + .into_iter() + .chain(first_or_empty(&self.view.keymap.jump_bottom)) + .collect(), + "to jump", + ), + ], + ); - let mut pairs: Vec<(&[KeyBinding], &str)> = vec![(&[KEY_Q], "to quit")]; + let mut pairs: Vec<(Vec, &str)> = + vec![(first_or_empty(&self.view.keymap.close), "to quit")]; if self.highlight_cell.is_some() { - pairs.push((&[KEY_ESC, KEY_LEFT], "to edit prev")); - pairs.push((&[KEY_RIGHT], "to edit next")); - pairs.push((&[KEY_ENTER], "to edit message")); + pairs.push(( + vec![ + key_hint::plain(KeyCode::Esc), + key_hint::plain(KeyCode::Left), + ], + "to edit prev", + )); + pairs.push((vec![key_hint::plain(KeyCode::Right)], "to edit next")); + pairs.push((vec![key_hint::plain(KeyCode::Enter)], "to edit message")); } else { - pairs.push((&[KEY_ESC], "to edit prev")); + pairs.push((vec![key_hint::plain(KeyCode::Esc)], "to edit prev")); } render_key_hints(line2, buf, &pairs); } @@ -737,7 +757,9 @@ impl TranscriptOverlay { pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> { match event { TuiEvent::Key(key_event) => match key_event { - e if KEY_Q.is_press(e) || KEY_CTRL_C.is_press(e) || KEY_CTRL_T.is_press(e) => { + e if self.view.keymap.close.is_pressed(e) + || self.view.keymap.close_transcript.is_pressed(e) => + { self.is_done = true; Ok(()) } @@ -768,14 +790,26 @@ pub(crate) struct StaticOverlay { } impl StaticOverlay { - pub(crate) fn with_title(lines: Vec>, title: String) -> Self { + pub(crate) fn with_title( + lines: Vec>, + title: String, + keymap: PagerKeymap, + ) -> Self { let paragraph = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }); - Self::with_renderables(vec![Box::new(CachedRenderable::new(paragraph))], title) + Self::with_renderables( + vec![Box::new(CachedRenderable::new(paragraph))], + title, + keymap, + ) } - pub(crate) fn with_renderables(renderables: Vec>, title: String) -> Self { + pub(crate) fn with_renderables( + renderables: Vec>, + title: String, + keymap: PagerKeymap, + ) -> Self { Self { - view: PagerView::new(renderables, title, /*scroll_offset*/ 0), + view: PagerView::new(renderables, title, /*scroll_offset*/ 0, keymap), is_done: false, } } @@ -783,8 +817,35 @@ impl StaticOverlay { fn render_hints(&self, area: Rect, buf: &mut Buffer) { let line1 = Rect::new(area.x, area.y, area.width, 1); let line2 = Rect::new(area.x, area.y.saturating_add(1), area.width, 1); - render_key_hints(line1, buf, PAGER_KEY_HINTS); - let pairs: Vec<(&[KeyBinding], &str)> = vec![(&[KEY_Q], "to quit")]; + render_key_hints( + line1, + buf, + &[ + ( + first_or_empty(&self.view.keymap.scroll_up) + .into_iter() + .chain(first_or_empty(&self.view.keymap.scroll_down)) + .collect(), + "to scroll", + ), + ( + first_or_empty(&self.view.keymap.page_up) + .into_iter() + .chain(first_or_empty(&self.view.keymap.page_down)) + .collect(), + "to page", + ), + ( + first_or_empty(&self.view.keymap.jump_top) + .into_iter() + .chain(first_or_empty(&self.view.keymap.jump_bottom)) + .collect(), + "to jump", + ), + ], + ); + let pairs: Vec<(Vec, &str)> = + vec![(first_or_empty(&self.view.keymap.close), "to quit")]; render_key_hints(line2, buf, &pairs); } @@ -801,7 +862,7 @@ impl StaticOverlay { pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> { match event { TuiEvent::Key(key_event) => match key_event { - e if KEY_Q.is_press(e) || KEY_CTRL_C.is_press(e) => { + e if self.view.keymap.close.is_pressed(e) => { self.is_done = true; Ok(()) } @@ -894,9 +955,34 @@ mod tests { Box::new(Paragraph::new(text)) as Box } + fn default_pager_keymap() -> crate::keymap::PagerKeymap { + crate::keymap::RuntimeKeymap::defaults().pager + } + + fn transcript_overlay(cells: Vec>) -> TranscriptOverlay { + TranscriptOverlay::new(cells, default_pager_keymap()) + } + + fn static_overlay(lines: Vec>, title: &str) -> StaticOverlay { + StaticOverlay::with_title(lines, title.to_string(), default_pager_keymap()) + } + + fn pager_view( + renderables: Vec>, + title: &str, + scroll_offset: usize, + ) -> PagerView { + PagerView::new( + renderables, + title.to_string(), + scroll_offset, + default_pager_keymap(), + ) + } + #[test] fn edit_prev_hint_is_visible() { - let mut overlay = TranscriptOverlay::new(vec![Arc::new(TestCell { + let mut overlay = transcript_overlay(vec![Arc::new(TestCell { lines: vec![Line::from("hello")], })]); @@ -914,7 +1000,7 @@ mod tests { #[test] fn edit_next_hint_is_visible_when_highlighted() { - let mut overlay = TranscriptOverlay::new(vec![Arc::new(TestCell { + let mut overlay = transcript_overlay(vec![Arc::new(TestCell { lines: vec![Line::from("hello")], })]); overlay.set_highlight_cell(Some(0)); @@ -934,7 +1020,7 @@ mod tests { #[test] fn transcript_overlay_snapshot_basic() { // Prepare a transcript overlay with a few lines - let mut overlay = TranscriptOverlay::new(vec![ + let mut overlay = transcript_overlay(vec![ Arc::new(TestCell { lines: vec![Line::from("alpha")], }), @@ -953,7 +1039,7 @@ mod tests { #[test] fn transcript_overlay_renders_live_tail() { - let mut overlay = TranscriptOverlay::new(vec![Arc::new(TestCell { + let mut overlay = transcript_overlay(vec![Arc::new(TestCell { lines: vec![Line::from("alpha")], })]); overlay.sync_live_tail( @@ -974,7 +1060,7 @@ mod tests { #[test] fn transcript_overlay_sync_live_tail_is_noop_for_identical_key() { - let mut overlay = TranscriptOverlay::new(vec![Arc::new(TestCell { + let mut overlay = transcript_overlay(vec![Arc::new(TestCell { lines: vec![Line::from("alpha")], })]); @@ -1070,7 +1156,7 @@ mod tests { let exec_cell: Arc = Arc::new(exec_cell); cells.push(exec_cell); - let mut overlay = TranscriptOverlay::new(cells); + let mut overlay = transcript_overlay(cells); let area = Rect::new(0, 0, 80, 12); let mut buf = Buffer::empty(area); @@ -1084,7 +1170,7 @@ mod tests { #[test] fn transcript_overlay_keeps_scroll_pinned_at_bottom() { - let mut overlay = TranscriptOverlay::new( + let mut overlay = transcript_overlay( (0..20) .map(|i| { Arc::new(TestCell { @@ -1111,7 +1197,7 @@ mod tests { #[test] fn transcript_overlay_preserves_manual_scroll_position() { - let mut overlay = TranscriptOverlay::new( + let mut overlay = transcript_overlay( (0..20) .map(|i| { Arc::new(TestCell { @@ -1135,7 +1221,7 @@ mod tests { #[test] fn transcript_overlay_consolidation_remaps_highlight_inside_range() { - let mut overlay = TranscriptOverlay::new( + let mut overlay = transcript_overlay( (0..6) .map(|i| { Arc::new(TestCell { @@ -1162,7 +1248,7 @@ mod tests { #[test] fn transcript_overlay_consolidation_remaps_highlight_after_range() { - let mut overlay = TranscriptOverlay::new( + let mut overlay = transcript_overlay( (0..7) .map(|i| { Arc::new(TestCell { @@ -1190,9 +1276,9 @@ mod tests { #[test] fn static_overlay_snapshot_basic() { // Prepare a static overlay with a few lines and a title - let mut overlay = StaticOverlay::with_title( + let mut overlay = static_overlay( vec!["one".into(), "two".into(), "three".into()], - "S T A T I C".to_string(), + "S T A T I C", ); let mut term = Terminal::new(TestBackend::new(40, 10)).expect("term"); term.draw(|f| overlay.render(f.area(), f.buffer_mut())) @@ -1228,7 +1314,7 @@ mod tests { #[test] fn transcript_overlay_paging_is_continuous_and_round_trips() { - let mut overlay = TranscriptOverlay::new( + let mut overlay = transcript_overlay( (0..50) .map(|i| { Arc::new(TestCell { @@ -1296,9 +1382,9 @@ mod tests { #[test] fn static_overlay_wraps_long_lines() { - let mut overlay = StaticOverlay::with_title( + let mut overlay = static_overlay( vec!["a very long line that should wrap when rendered within a narrow pager overlay width".into()], - "S T A T I C".to_string(), + "S T A T I C", ); let mut term = Terminal::new(TestBackend::new(24, 8)).expect("term"); term.draw(|f| overlay.render(f.area(), f.buffer_mut())) @@ -1308,12 +1394,12 @@ mod tests { #[test] fn pager_view_content_height_counts_renderables() { - let pv = PagerView::new( + let pv = pager_view( vec![ paragraph_block("a", /*lines*/ 2), paragraph_block("b", /*lines*/ 3), ], - "T".to_string(), + "T", /*scroll_offset*/ 0, ); @@ -1322,13 +1408,13 @@ mod tests { #[test] fn pager_view_ensure_chunk_visible_scrolls_down_when_needed() { - let mut pv = PagerView::new( + let mut pv = pager_view( vec![ paragraph_block("a", /*lines*/ 1), paragraph_block("b", /*lines*/ 3), paragraph_block("c", /*lines*/ 3), ], - "T".to_string(), + "T", /*scroll_offset*/ 0, ); let area = Rect::new(0, 0, 20, 8); @@ -1357,13 +1443,13 @@ mod tests { #[test] fn pager_view_ensure_chunk_visible_scrolls_up_when_needed() { - let mut pv = PagerView::new( + let mut pv = pager_view( vec![ paragraph_block("a", /*lines*/ 2), paragraph_block("b", /*lines*/ 3), paragraph_block("c", /*lines*/ 3), ], - "T".to_string(), + "T", /*scroll_offset*/ 0, ); let area = Rect::new(0, 0, 20, 3); @@ -1376,9 +1462,9 @@ mod tests { #[test] fn pager_view_is_scrolled_to_bottom_accounts_for_wrapped_height() { - let mut pv = PagerView::new( + let mut pv = pager_view( vec![paragraph_block("a", /*lines*/ 10)], - "T".to_string(), + "T", /*scroll_offset*/ 0, ); let area = Rect::new(0, 0, 20, 8); diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 55dc36d53c..4f7714505c 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -16,6 +16,7 @@ pub enum SlashCommand { Fast, Approvals, Permissions, + Keymap, #[strum(serialize = "setup-default-sandbox")] ElevateSandbox, #[strum(serialize = "sandbox-add-read-dir")] @@ -113,6 +114,7 @@ impl SlashCommand { SlashCommand::Side => "start a side conversation in an ephemeral fork", SlashCommand::Approvals => "choose what Codex is allowed to do", SlashCommand::Permissions => "choose what Codex is allowed to do", + SlashCommand::Keymap => "remap TUI shortcuts", SlashCommand::ElevateSandbox => "set up elevated agent sandbox", SlashCommand::SandboxReadRoot => { "let sandbox read a directory: /sandbox-add-read-dir " @@ -173,6 +175,7 @@ impl SlashCommand { | SlashCommand::Personality | SlashCommand::Approvals | SlashCommand::Permissions + | SlashCommand::Keymap | SlashCommand::ElevateSandbox | SlashCommand::SandboxReadRoot | SlashCommand::Experimental diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_action_menu.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_action_menu.snap new file mode 100644 index 0000000000..e1331c355a --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_action_menu.snap @@ -0,0 +1,25 @@ +--- +source: tui/src/keymap_setup.rs +expression: snapshot +--- +unbound: +Set key | Capture a key for this unbound action. | enabled +Remove custom binding | Restore the default keymap binding. | enabled +Back to shortcuts | Return to the shortcut list. | enabled + +single: +Replace binding | Capture a replacement key. | enabled +Add alternate binding | Keep the current binding and add another key. | enabled +Remove custom binding | Restore the default keymap binding. | enabled +Back to shortcuts | Return to the shortcut list. | enabled + +multi: +Replace one binding... | Choose which existing binding to replace. | enabled +Replace all bindings | Replace every current binding with one key. | enabled +Add alternate binding | Keep current bindings and add another key. | enabled +Remove custom binding | Restore the default keymap binding. | enabled +Back to shortcuts | Return to the shortcut list. | enabled + +replace picker: +ctrl-enter | Replace this binding. | enabled +alt-enter | Replace this binding. | enabled diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_capture_view.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_capture_view.snap new file mode 100644 index 0000000000..076afd344c --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_capture_view.snap @@ -0,0 +1,30 @@ +--- +source: tui/src/keymap_setup.rs +expression: "format!(\"{:?}\", render_capture(&view, 80, 8))" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 80, height: 8 }, + content: [ + "Remap Shortcut ", + "Action: Submit composer.submit ", + "Current: enter ", + "Press the new key now. Esc cancels. ", + " ", + " ", + " ", + " ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 14, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 8, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 16, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 31, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 9, y: 2, fg: Cyan, bg: Reset, underline: Reset, modifier: NONE, + x: 14, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 35, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap new file mode 100644 index 0000000000..725fa820af --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap @@ -0,0 +1,16 @@ +--- +source: tui/src/keymap_setup.rs +expression: snapshot +--- +Open Transcript | ctrl-t | Global open_transcript Open Transcript Open the transcript overlay. ctrl-t Default +Open External Editor | ctrl-g | Global open_external_editor Open External Editor Open the current draft in an external editor. ctrl-g Default +Copy | ctrl-o | Global copy Copy Copy the last agent response to the clipboard. ctrl-o Default +Clear Terminal | ctrl-l | Global clear_terminal Clear Terminal Clear the terminal UI. ctrl-l Default +Decrease Reasoning Effort | alt-, | Chat decrease_reasoning_effort Decrease Reasoning Effort Decrease reasoning effort. alt-, Default +Increase Reasoning Effort | alt-. | Chat increase_reasoning_effort Increase Reasoning Effort Increase reasoning effort. alt-. Default +Edit Queued Message | alt-up, shift-left | Chat edit_queued_message Edit Queued Message Edit the most recently queued message. alt-up, shift-left Default +Submit | enter | Composer submit Submit Submit the current composer draft. enter Default +Queue | tab | Composer queue Queue Queue the draft while a task is running. tab Default +Toggle Shortcuts | ?, shift-? | Composer toggle_shortcuts Toggle Shortcuts Show or hide the composer shortcut overlay. ?, shift-? Default +History Search Previous | ctrl-r | Composer history_search_previous History Search Previous Open history search or move to the previous match. ctrl-r Default +History Search Next | ctrl-s | Composer history_search_next History Search Next Move to the next history search match. ctrl-s Default diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap new file mode 100644 index 0000000000..114eab926a --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap @@ -0,0 +1,22 @@ +--- +source: tui/src/keymap_setup.rs +expression: "render_picker(params, 120)" +--- + + Keymap + All configurable shortcuts. + 50 actions, 1 customized, 0 unbound. + + [All] Common Customized (1) Unbound (0) App Composer Editor Navigation Approval + + Type to search shortcuts +› Global Open Transcript ctrl-t + Global Open External Editor ctrl-g + Global Copy ctrl-o + Global Clear Terminal ctrl-l + Chat Decrease Reasoning Effort alt-, + Chat Increase Reasoning Effort alt-. + Chat Edit Queued Message alt-up, shift-left + Composer * Submit ctrl-enter + + left/right group · enter edit shortcut · * custom · - unbound · esc close diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap new file mode 100644 index 0000000000..395324147a --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap @@ -0,0 +1,25 @@ +--- +source: tui/src/keymap_setup.rs +expression: snapshot +--- +tab: All (50 selectable) +tab: Common (18 selectable) +tab: Customized (0) (0 selectable) +tab: Unbound (0) (0 selectable) +tab: App (7 selectable) +tab: Composer (5 selectable) +tab: Editor (16 selectable) +tab: Navigation (14 selectable) +tab: Approval (8 selectable) +Open Transcript | ctrl-t | Global open_transcript Open Transcript Open the transcript overlay. ctrl-t Default +Open External Editor | ctrl-g | Global open_external_editor Open External Editor Open the current draft in an external editor. ctrl-g Default +Copy | ctrl-o | Global copy Copy Copy the last agent response to the clipboard. ctrl-o Default +Clear Terminal | ctrl-l | Global clear_terminal Clear Terminal Clear the terminal UI. ctrl-l Default +Decrease Reasoning Effort | alt-, | Chat decrease_reasoning_effort Decrease Reasoning Effort Decrease reasoning effort. alt-, Default +Increase Reasoning Effort | alt-. | Chat increase_reasoning_effort Increase Reasoning Effort Increase reasoning effort. alt-. Default +Edit Queued Message | alt-up, shift-left | Chat edit_queued_message Edit Queued Message Edit the most recently queued message. alt-up, shift-left Default +Submit | enter | Composer submit Submit Submit the current composer draft. enter Default +Queue | tab | Composer queue Queue Queue the draft while a task is running. tab Default +Toggle Shortcuts | ?, shift-? | Composer toggle_shortcuts Toggle Shortcuts Show or hide the composer shortcut overlay. ?, shift-? Default +History Search Previous | ctrl-r | Composer history_search_previous History Search Previous Open history search or move to the previous match. ctrl-r Default +History Search Next | ctrl-s | Composer history_search_next History Search Next Move to the next history search match. ctrl-s Default diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap new file mode 100644 index 0000000000..d044094b0a --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap @@ -0,0 +1,23 @@ +--- +source: tui/src/keymap_setup.rs +expression: "render_picker(params, 78)" +--- + + Keymap + All configurable shortcuts. + 50 actions, 0 customized, 0 unbound. + + [All] Common Customized (0) Unbound (0) App Composer Editor + Navigation Approval + + Type to search shortcuts +› Global Open Transcript ctrl-t + Global Open External Editor ctrl-g + Global Copy ctrl-o + Global Clear Terminal ctrl-l + Chat Decrease Reasoning Effort alt-, + Chat Increase Reasoning Effort alt-. + Chat Edit Queued Message alt-up, shift-left + Composer Submit enter + + left/right group · enter edit shortcut · * custom · - unbound · esc close diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap new file mode 100644 index 0000000000..cfc0a831b9 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap @@ -0,0 +1,22 @@ +--- +source: tui/src/keymap_setup.rs +expression: "render_picker(params, 120)" +--- + + Keymap + All configurable shortcuts. + 50 actions, 0 customized, 0 unbound. + + [All] Common Customized (0) Unbound (0) App Composer Editor Navigation Approval + + Type to search shortcuts +› Global Open Transcript ctrl-t + Global Open External Editor ctrl-g + Global Copy ctrl-o + Global Clear Terminal ctrl-l + Chat Decrease Reasoning Effort alt-, + Chat Increase Reasoning Effort alt-. + Chat Edit Queued Message alt-up, shift-left + Composer Submit enter + + left/right group · enter edit shortcut · * custom · - unbound · esc close diff --git a/codex-rs/tui/tooltips.txt b/codex-rs/tui/tooltips.txt index 7ddaa94500..6927ad5111 100644 --- a/codex-rs/tui/tooltips.txt +++ b/codex-rs/tui/tooltips.txt @@ -21,6 +21,8 @@ You can run any shell command from Codex using `!` (e.g. `!ls`) Type / to open the command popup; Tab autocompletes slash commands. When the composer is empty, press Esc to step back and edit your last message; Enter confirms. Press Tab to queue a message when a task is running; otherwise it sends immediately (except `!`). +[tui.keymap] in ~/.codex/config.toml lets you rebind supported shortcuts. +See the Codex keymap documentation for supported actions and examples. Paste an image with Ctrl+V to attach it to your next message. You can resume a previous conversation by running `codex resume` Use /copy or press Ctrl+O to copy the latest agent response as Markdown. From 0156b1e61f675308a7019ead7e33f3767d6f2161 Mon Sep 17 00:00:00 2001 From: evawong-oai Date: Tue, 28 Apr 2026 09:10:41 -0700 Subject: [PATCH 17/53] [sandbox] Enforce protected workspace metadata paths (#19846) ## Summary Make FileSystemSandboxPolicy the semantic source of truth for project root metadata protection. Under writable roots, `.git`, `.codex`, and `.agents` stay protected unless user policy grants an explicit write rule for that metadata path. ## Scope 1. Add `protected_metadata_names` to `WritableRoot`. 2. Teach `FileSystemSandboxPolicy::can_write_path_with_cwd` to reject protected metadata writes under writable roots unless explicitly allowed. 3. Default workspace write profiles to protect `.git`, `.codex`, and `.agents`. 4. Add the Linux fallback setup needed before Linux enforcement lands later in the stack. ## Reviewer Focus 1. The policy decision belongs in FileSystemSandboxPolicy, not shell command parsing. 2. Legacy SandboxPolicy remains a compatibility projection, not the source of the new rule. 3. Explicit user write rules can still opt into these metadata paths. ## Stack 1. Policy primitive: this PR 2. macOS Seatbelt adapter: #19847 3. Shell preflight UX: #19848 4. Runtime profile propagation: #19849 5. Linux bubblewrap adapter: #19852 ## Validation 1. codex protocol permissions tests 2. formatting for codex protocol and codex linux sandbox 3. diff whitespace check --- codex-rs/linux-sandbox/src/bwrap.rs | 1 + codex-rs/protocol/src/permissions.rs | 367 ++++++++++++++++++++++----- codex-rs/protocol/src/protocol.rs | 128 ++-------- 3 files changed, 326 insertions(+), 170 deletions(-) diff --git a/codex-rs/linux-sandbox/src/bwrap.rs b/codex-rs/linux-sandbox/src/bwrap.rs index f2adf3d049..e191a924d3 100644 --- a/codex-rs/linux-sandbox/src/bwrap.rs +++ b/codex-rs/linux-sandbox/src/bwrap.rs @@ -259,6 +259,7 @@ fn create_filesystem_args( writable_roots.push(WritableRoot { root: AbsolutePathBuf::from_absolute_path("/")?, read_only_subpaths: Vec::new(), + protected_metadata_names: Vec::new(), }); } let missing_auto_metadata_read_only_project_root_subpaths: HashSet = diff --git a/codex-rs/protocol/src/permissions.rs b/codex-rs/protocol/src/permissions.rs index 2ce5194e06..c1f3d0724b 100644 --- a/codex-rs/protocol/src/permissions.rs +++ b/codex-rs/protocol/src/permissions.rs @@ -19,6 +19,62 @@ use crate::protocol::NetworkAccess; use crate::protocol::SandboxPolicy; use crate::protocol::WritableRoot; +const PROTECTED_METADATA_GIT_PATH_NAME: &str = ".git"; +const PROTECTED_METADATA_AGENTS_PATH_NAME: &str = ".agents"; +const PROTECTED_METADATA_CODEX_PATH_NAME: &str = ".codex"; + +/// Top-level workspace metadata paths that stay protected under writable roots. +pub const PROTECTED_METADATA_PATH_NAMES: &[&str] = &[ + PROTECTED_METADATA_GIT_PATH_NAME, + PROTECTED_METADATA_AGENTS_PATH_NAME, + PROTECTED_METADATA_CODEX_PATH_NAME, +]; + +/// Returns true when a path basename is one of the protected workspace metadata names. +pub fn is_protected_metadata_name(name: &OsStr) -> bool { + PROTECTED_METADATA_PATH_NAMES + .iter() + .any(|metadata_name| name == OsStr::new(metadata_name)) +} + +pub fn is_protected_metadata_directory_name(name: &OsStr) -> bool { + name == OsStr::new(PROTECTED_METADATA_AGENTS_PATH_NAME) + || name == OsStr::new(PROTECTED_METADATA_CODEX_PATH_NAME) +} + +/// Returns the protected workspace metadata name when an agent write to `path` +/// should be blocked before execution. +pub fn forbidden_agent_metadata_write( + path: &Path, + cwd: &Path, + file_system_sandbox_policy: &FileSystemSandboxPolicy, +) -> Option<&'static str> { + if !matches!( + file_system_sandbox_policy.kind, + FileSystemSandboxKind::Restricted + ) { + return None; + } + + let target = resolve_candidate_path(path, cwd)?; + let (protected_metadata_path, metadata_name) = + metadata_child_of_writable_root(file_system_sandbox_policy, target.as_path(), cwd)?; + if has_explicit_write_entry_for_metadata_path( + file_system_sandbox_policy, + &protected_metadata_path, + target.as_path(), + cwd, + ) { + return None; + } + + if !file_system_sandbox_policy.can_write_path_with_cwd(target.as_path(), cwd) { + return Some(metadata_name); + } + + None +} + #[derive( Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, Default, JsonSchema, TS, )] @@ -570,7 +626,35 @@ impl FileSystemSandboxPolicy { } pub fn can_write_path_with_cwd(&self, path: &Path, cwd: &Path) -> bool { - self.resolve_access_with_cwd(path, cwd).can_write() + if !self.resolve_access_with_cwd(path, cwd).can_write() { + return false; + } + if self.has_full_disk_write_access() { + return true; + } + !self.is_metadata_write_denied(path, cwd) + } + + fn is_metadata_write_denied(&self, path: &Path, cwd: &Path) -> bool { + if !matches!(self.kind, FileSystemSandboxKind::Restricted) { + return false; + } + + let Some(target) = resolve_candidate_path(path, cwd) else { + return true; + }; + let Some((protected_metadata_path, _)) = + metadata_child_of_writable_root(self, target.as_path(), cwd) + else { + return false; + }; + + !has_explicit_write_entry_for_metadata_path( + self, + &protected_metadata_path, + target.as_path(), + cwd, + ) } /// Replaces symbolic `:project_roots` entries with absolute paths resolved @@ -688,6 +772,10 @@ impl FileSystemSandboxPolicy { return true; }; + if protected_metadata_names_need_direct_runtime_enforcement(self, &legacy_policy, cwd) { + return true; + } + self.semantic_signature(cwd) != legacy_runtime_file_system_policy_for_cwd(&legacy_policy, cwd) .semantic_signature(cwd) @@ -749,6 +837,8 @@ impl FileSystemSandboxPolicy { .iter() .filter(|path| normalize_effective_absolute_path((*path).clone()) == root) .collect(); + let protected_metadata_names = + protected_metadata_names_for_writable_root(self, &root, &raw_writable_roots, cwd); let protect_missing_dot_codex = AbsolutePathBuf::from_absolute_path(cwd) .ok() .is_some_and(|cwd| normalize_effective_absolute_path(cwd) == root); @@ -816,6 +906,7 @@ impl FileSystemSandboxPolicy { }), ); WritableRoot { + protected_metadata_names, root, // Preserve literal in-root protected paths like `.git` and // `.codex` so downstream sandboxes can still detect and mask @@ -1084,7 +1175,7 @@ fn resolve_candidate_path(path: &Path, cwd: &Path) -> Option { if path.is_absolute() { AbsolutePathBuf::from_absolute_path(path).ok() } else { - Some(AbsolutePathBuf::resolve_path_against_base(path, cwd)) + Some(AbsolutePathBuf::from_absolute_path(cwd).ok()?.join(path)) } } @@ -1277,6 +1368,8 @@ fn sorted_writable_roots(mut roots: Vec) -> Vec { for root in &mut roots { root.read_only_subpaths = sorted_absolute_paths(std::mem::take(&mut root.read_only_subpaths)); + root.protected_metadata_names.sort(); + root.protected_metadata_names.dedup(); } roots.sort_by(|left, right| left.root.as_path().cmp(right.root.as_path())); roots @@ -1303,18 +1396,19 @@ fn normalize_effective_absolute_path(path: AbsolutePathBuf) -> AbsolutePathBuf { path } -fn default_read_only_subpaths_for_writable_root( +pub(crate) fn default_read_only_subpaths_for_writable_root( writable_root: &AbsolutePathBuf, protect_missing_dot_codex: bool, ) -> Vec { let mut subpaths: Vec = Vec::new(); - let top_level_git = writable_root.join(".git"); + let top_level_git = writable_root.join(PROTECTED_METADATA_GIT_PATH_NAME); // This applies to typical repos (directory .git), worktrees/submodules // (file .git with gitdir pointer), and bare repos when the gitdir is the // writable root itself. let top_level_git_is_file = top_level_git.as_path().is_file(); let top_level_git_is_dir = top_level_git.as_path().is_dir(); - if top_level_git_is_dir || top_level_git_is_file { + let should_protect_top_level = top_level_git_is_dir || top_level_git_is_file; + if should_protect_top_level { if top_level_git_is_file && is_git_pointer_file(&top_level_git) && let Some(gitdir) = resolve_gitdir_from_file(&top_level_git) @@ -1324,7 +1418,7 @@ fn default_read_only_subpaths_for_writable_root( subpaths.push(top_level_git); } - let top_level_agents = writable_root.join(".agents"); + let top_level_agents = writable_root.join(PROTECTED_METADATA_AGENTS_PATH_NAME); if top_level_agents.as_path().is_dir() { subpaths.push(top_level_agents); } @@ -1333,7 +1427,7 @@ fn default_read_only_subpaths_for_writable_root( // default. For the workspace root itself, protect it even before the // directory exists so first-time creation still goes through the // protected-path approval flow. - let top_level_codex = writable_root.join(".codex"); + let top_level_codex = writable_root.join(PROTECTED_METADATA_CODEX_PATH_NAME); if protect_missing_dot_codex || top_level_codex.as_path().is_dir() { subpaths.push(top_level_codex); } @@ -1465,8 +1559,105 @@ fn has_explicit_resolved_path_entry( entries.iter().any(|entry| &entry.path == path) } +fn metadata_path_name(name: &OsStr) -> Option<&'static str> { + PROTECTED_METADATA_PATH_NAMES + .iter() + .copied() + .find(|metadata_name| name == OsStr::new(metadata_name)) +} + +fn metadata_child_of_writable_root( + policy: &FileSystemSandboxPolicy, + target: &Path, + cwd: &Path, +) -> Option<(AbsolutePathBuf, &'static str)> { + policy + .resolved_entries_with_cwd(cwd) + .iter() + .filter(|entry| entry.access.can_write()) + .filter_map(|entry| { + let relative_path = target.strip_prefix(entry.path.as_path()).ok()?; + let first_component = relative_path.components().next()?; + let metadata_name = metadata_path_name(first_component.as_os_str())?; + Some((entry.path.join(metadata_name), metadata_name)) + }) + .next() +} + +fn protected_metadata_names_for_writable_root( + policy: &FileSystemSandboxPolicy, + root: &AbsolutePathBuf, + raw_writable_roots: &[&AbsolutePathBuf], + cwd: &Path, +) -> Vec { + let mut protected_names = Vec::new(); + for metadata_name in PROTECTED_METADATA_PATH_NAMES { + let mut metadata_paths = vec![root.join(*metadata_name)]; + metadata_paths.extend( + raw_writable_roots + .iter() + .map(|raw_root| raw_root.join(*metadata_name)), + ); + + if metadata_paths + .iter() + .all(|metadata_path| !policy.can_write_path_with_cwd(metadata_path.as_path(), cwd)) + { + protected_names.push((*metadata_name).to_string()); + } + } + protected_names +} + +fn protected_metadata_names_need_direct_runtime_enforcement( + policy: &FileSystemSandboxPolicy, + legacy_policy: &SandboxPolicy, + cwd: &Path, +) -> bool { + let legacy_roots = legacy_policy.get_writable_roots_with_cwd(cwd); + policy + .get_writable_roots_with_cwd(cwd) + .into_iter() + .any(|writable_root| { + let Some(legacy_root) = legacy_roots + .iter() + .find(|candidate| candidate.root == writable_root.root) + else { + return !writable_root.protected_metadata_names.is_empty(); + }; + + writable_root + .protected_metadata_names + .iter() + .any(|metadata_name| { + let metadata_path = writable_root.root.join(metadata_name); + !legacy_root + .read_only_subpaths + .iter() + .any(|subpath| subpath == &metadata_path) + }) + }) +} + +fn has_explicit_write_entry_for_metadata_path( + policy: &FileSystemSandboxPolicy, + protected_metadata_path: &AbsolutePathBuf, + target: &Path, + cwd: &Path, +) -> bool { + policy.resolved_entries_with_cwd(cwd).iter().any(|entry| { + entry.access.can_write() + && target.starts_with(entry.path.as_path()) + && entry + .path + .as_path() + .starts_with(protected_metadata_path.as_path()) + }) +} + fn is_git_pointer_file(path: &AbsolutePathBuf) -> bool { - path.as_path().is_file() && path.as_path().file_name() == Some(OsStr::new(".git")) + path.as_path().is_file() + && path.as_path().file_name() == Some(OsStr::new(PROTECTED_METADATA_GIT_PATH_NAME)) } fn resolve_gitdir_from_file(dot_git: &AbsolutePathBuf) -> Option { @@ -1483,7 +1674,14 @@ fn resolve_gitdir_from_file(dot_git: &AbsolutePathBuf) -> Option parts, + Some((prefix, gitdir_raw)) if prefix.trim() == "gitdir" => (prefix, gitdir_raw), + Some(_) => { + error!( + "Expected {path} to contain a gitdir pointer, but it did not match `gitdir: `.", + path = dot_git.as_path().display() + ); + return None; + } None => { error!( "Expected {path} to contain a gitdir pointer, but it did not match `gitdir: `.", @@ -1696,6 +1894,12 @@ mod tests { .iter() .find(|root| root.root == expected_root) .expect("workspace writable root"); + assert!( + !workspace_root + .protected_metadata_names + .contains(&".codex".to_string()), + "explicit .codex rule should remove the metadata-name protection" + ); assert!( !workspace_root .read_only_subpaths @@ -1711,32 +1915,46 @@ mod tests { } #[test] - fn legacy_workspace_write_projection_blocks_missing_dot_codex_writes() { + fn filesystem_policy_blocks_protected_metadata_path_writes_by_default() { let cwd = TempDir::new().expect("tempdir"); + let dot_git_config = cwd.path().join(".git").join("config"); + let dot_agents_config = cwd.path().join(".agents").join("config"); let dot_codex_config = cwd.path().join(".codex").join("config.toml"); - let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }; - + let root = AbsolutePathBuf::from_absolute_path(cwd.path()).expect("absolute cwd"); let file_system_policy = - FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&policy, cwd.path()); + FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::Path { path: root }, + access: FileSystemAccessMode::Write, + }]); + assert!(!file_system_policy.can_write_path_with_cwd(&dot_git_config, cwd.path())); + assert!(!file_system_policy.can_write_path_with_cwd(&dot_agents_config, cwd.path())); assert!(!file_system_policy.can_write_path_with_cwd(&dot_codex_config, cwd.path())); + + let writable_roots = file_system_policy.get_writable_roots_with_cwd(cwd.path()); + assert_eq!(writable_roots.len(), 1); + assert_eq!( + writable_roots[0].protected_metadata_names, + vec![ + ".git".to_string(), + ".agents".to_string(), + ".codex".to_string(), + ] + ); + assert!(!writable_roots[0].is_path_writable(&dot_git_config)); + assert!(!writable_roots[0].is_path_writable(&dot_agents_config)); + assert!(!writable_roots[0].is_path_writable(&dot_codex_config)); } #[test] fn legacy_workspace_write_projection_accepts_relative_cwd() { let relative_cwd = Path::new("workspace"); - let expected_dot_codex = AbsolutePathBuf::from_absolute_path( + let expected_root = AbsolutePathBuf::from_absolute_path( std::env::current_dir() .expect("current dir") - .join(relative_cwd) - .join(".codex"), + .join(relative_cwd), ) - .expect("absolute dot codex"); + .expect("absolute root"); let policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], network_access: false, @@ -1747,51 +1965,62 @@ mod tests { let file_system_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&policy, relative_cwd); + let mut expected_entries = vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(/*subpath*/ None), + }, + access: FileSystemAccessMode::Write, + }, + ]; + expected_entries.extend(PROTECTED_METADATA_PATH_NAMES.iter().map(|name| { + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(Some((*name).into())), + }, + access: FileSystemAccessMode::Read, + } + })); + expected_entries.extend( + default_read_only_subpaths_for_writable_root( + &expected_root, + /*protect_missing_dot_codex*/ true, + ) + .into_iter() + .map(|path| FileSystemSandboxEntry { + path: FileSystemPath::Path { path }, + access: FileSystemAccessMode::Read, + }), + ); + assert_eq!( file_system_policy, - FileSystemSandboxPolicy::restricted(vec![ - FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Root, - }, - access: FileSystemAccessMode::Read, - }, - FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::project_roots(/*subpath*/ None), - }, - access: FileSystemAccessMode::Write, - }, - FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::project_roots(Some(".git".into())), - }, - access: FileSystemAccessMode::Read, - }, - FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::project_roots(Some(".agents".into())), - }, - access: FileSystemAccessMode::Read, - }, - FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::project_roots(Some(".codex".into())), - }, - access: FileSystemAccessMode::Read, - }, - FileSystemSandboxEntry { - path: FileSystemPath::Path { - path: expected_dot_codex, - }, - access: FileSystemAccessMode::Read, - }, - ]) + FileSystemSandboxPolicy::restricted(expected_entries) + ); + assert_eq!( + forbidden_agent_metadata_write( + Path::new(".git/config"), + relative_cwd, + &file_system_policy, + ), + Some(".git") ); assert!( !file_system_policy .can_write_path_with_cwd(Path::new(".codex/config.toml"), relative_cwd,) ); + assert!( + !file_system_policy.can_write_path_with_cwd( + Path::new(".agents/skills/example/SKILL.md"), + relative_cwd, + ) + ); } #[cfg(unix)] @@ -2233,8 +2462,9 @@ mod tests { cwd.path(), ); assert!( - !legacy_workspace_write - .needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path(),) + legacy_workspace_write + .needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path(),), + "metadata-name protections must stay in the direct enforcement path even when legacy concrete read-only paths match" ); } @@ -2256,9 +2486,12 @@ mod tests { legacy_order.is_semantically_equivalent_to(&reordered, cwd.path()), "entry order should not affect filesystem semantics" ); - assert!( - !reordered - .needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path()) + assert_eq!( + legacy_order + .needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path()), + reordered + .needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path()), + "entry order should not affect direct-enforcement classification" ); } @@ -2283,9 +2516,9 @@ mod tests { let legacy_runtime_projection = legacy_runtime_file_system_policy_for_cwd(&legacy_policy, cwd.path()); assert!( - !legacy_runtime_projection + legacy_runtime_projection .needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path()), - "true legacy runtime expansion should still classify as legacy-compatible" + "metadata-name protections are outside the legacy SandboxPolicy writable-root contract" ); } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 2876627025..34e5a47945 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -4,8 +4,6 @@ //! between user and agent. use std::collections::HashMap; -use std::collections::HashSet; -use std::ffi::OsStr; use std::fmt; use std::ops::Mul; use std::path::Path; @@ -84,6 +82,7 @@ pub use crate::permissions::FileSystemSandboxKind; pub use crate::permissions::FileSystemSandboxPolicy; pub use crate::permissions::FileSystemSpecialPath; pub use crate::permissions::NetworkSandboxPolicy; +use crate::permissions::default_read_only_subpaths_for_writable_root; pub use crate::request_permissions::RequestPermissionsArgs; pub use crate::request_user_input::RequestUserInputEvent; @@ -1087,6 +1086,11 @@ pub struct WritableRoot { /// By construction, these subpaths are all under `root`. pub read_only_subpaths: Vec, + + /// Workspace metadata path names that must not be created or replaced under + /// `root` unless the policy grants an explicit write rule for that metadata + /// path. + pub protected_metadata_names: Vec, } impl WritableRoot { @@ -1103,8 +1107,26 @@ impl WritableRoot { } } + if self.path_contains_protected_metadata_name(path) { + return false; + } + true } + + fn path_contains_protected_metadata_name(&self, path: &Path) -> bool { + let Ok(relative_path) = path.strip_prefix(&self.root) else { + return false; + }; + + let Some(first_component) = relative_path.components().next() else { + return false; + }; + + self.protected_metadata_names + .iter() + .any(|name| first_component.as_os_str() == std::ffi::OsStr::new(name)) + } } impl FromStr for SandboxPolicy { @@ -1257,6 +1279,7 @@ impl SandboxPolicy { &writable_root, protect_missing_dot_codex, ), + protected_metadata_names: Vec::new(), root: writable_root, } }) @@ -1266,107 +1289,6 @@ impl SandboxPolicy { } } -fn default_read_only_subpaths_for_writable_root( - writable_root: &AbsolutePathBuf, - protect_missing_dot_codex: bool, -) -> Vec { - let mut subpaths: Vec = Vec::new(); - let top_level_git = writable_root.join(".git"); - // This applies to typical repos (directory .git), worktrees/submodules - // (file .git with gitdir pointer), and bare repos when the gitdir is the - // writable root itself. - let top_level_git_is_file = top_level_git.as_path().is_file(); - let top_level_git_is_dir = top_level_git.as_path().is_dir(); - if top_level_git_is_dir || top_level_git_is_file { - if top_level_git_is_file - && is_git_pointer_file(&top_level_git) - && let Some(gitdir) = resolve_gitdir_from_file(&top_level_git) - { - subpaths.push(gitdir); - } - subpaths.push(top_level_git); - } - - let top_level_agents = writable_root.join(".agents"); - if top_level_agents.as_path().is_dir() { - subpaths.push(top_level_agents); - } - - // Keep top-level project metadata under .codex read-only to the agent by - // default. For the workspace root itself, protect it even before the - // directory exists so first-time creation still goes through the - // protected-path approval flow. - let top_level_codex = writable_root.join(".codex"); - if protect_missing_dot_codex || top_level_codex.as_path().is_dir() { - subpaths.push(top_level_codex); - } - - let mut deduped = Vec::with_capacity(subpaths.len()); - let mut seen = HashSet::new(); - for path in subpaths { - if seen.insert(path.to_path_buf()) { - deduped.push(path); - } - } - deduped -} - -fn is_git_pointer_file(path: &AbsolutePathBuf) -> bool { - path.as_path().is_file() && path.as_path().file_name() == Some(OsStr::new(".git")) -} - -fn resolve_gitdir_from_file(dot_git: &AbsolutePathBuf) -> Option { - let contents = match std::fs::read_to_string(dot_git.as_path()) { - Ok(contents) => contents, - Err(err) => { - error!( - "Failed to read {path} for gitdir pointer: {err}", - path = dot_git.as_path().display() - ); - return None; - } - }; - - let trimmed = contents.trim(); - let (_, gitdir_raw) = match trimmed.split_once(':') { - Some(parts) => parts, - None => { - error!( - "Expected {path} to contain a gitdir pointer, but it did not match `gitdir: `.", - path = dot_git.as_path().display() - ); - return None; - } - }; - let gitdir_raw = gitdir_raw.trim(); - if gitdir_raw.is_empty() { - error!( - "Expected {path} to contain a gitdir pointer, but it was empty.", - path = dot_git.as_path().display() - ); - return None; - } - let base = match dot_git.as_path().parent() { - Some(base) => base, - None => { - error!( - "Unable to resolve parent directory for {path}.", - path = dot_git.as_path().display() - ); - return None; - } - }; - let gitdir_path = AbsolutePathBuf::resolve_path_against_base(gitdir_raw, base); - if !gitdir_path.as_path().exists() { - error!( - "Resolved gitdir path {path} does not exist.", - path = gitdir_path.as_path().display() - ); - return None; - } - Some(gitdir_path) -} - /// Event Queue Entry - events from agent #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Event { From 5b7d6f5c4f5520d1abbf04df393ef18f0914c0f5 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 28 Apr 2026 18:13:35 +0200 Subject: [PATCH 18/53] feat: house-keeping memories 3 (#20005) Move stuff in memories, no behavioural change expected --- codex-rs/memories/write/src/extensions.rs | 9 +- .../memories/write/src/extensions_tests.rs | 7 +- codex-rs/memories/write/src/guard.rs | 4 +- codex-rs/memories/write/src/guard_tests.rs | 2 +- codex-rs/memories/write/src/lib.rs | 91 ++++++++++++++++--- codex-rs/memories/write/src/phase1.rs | 26 ++---- codex-rs/memories/write/src/phase2.rs | 31 ++++--- codex-rs/memories/write/src/prompts.rs | 39 +------- codex-rs/memories/write/src/prompts_tests.rs | 4 +- codex-rs/memories/write/src/workspace.rs | 16 ++-- .../memories/write/src/workspace_tests.rs | 4 +- 11 files changed, 127 insertions(+), 106 deletions(-) diff --git a/codex-rs/memories/write/src/extensions.rs b/codex-rs/memories/write/src/extensions.rs index f764b19c16..7b770cdf06 100644 --- a/codex-rs/memories/write/src/extensions.rs +++ b/codex-rs/memories/write/src/extensions.rs @@ -6,15 +6,12 @@ use chrono::Utc; use std::path::Path; use tracing::warn; -const FILENAME_TS_FORMAT: &str = "%Y-%m-%dT%H-%M-%S"; -const EXTENSION_RESOURCE_RETENTION_DAYS: i64 = 7; - pub async fn prune_old_extension_resources(memory_root: &Path) { prune_old_extension_resources_with_now(memory_root, Utc::now()).await } async fn prune_old_extension_resources_with_now(memory_root: &Path, now: DateTime) { - let cutoff = now - Duration::days(EXTENSION_RESOURCE_RETENTION_DAYS); + let cutoff = now - Duration::days(crate::extension_resources::RETENTION_DAYS); let extensions_root = memory_extensions_root(memory_root); let mut extensions = match tokio::fs::read_dir(&extensions_root).await { Ok(extensions) => extensions, @@ -92,7 +89,9 @@ async fn prune_old_extension_resources_with_now(memory_root: &Path, now: DateTim fn resource_timestamp(file_name: &str) -> Option> { let timestamp = file_name.get(..19)?; - let naive = NaiveDateTime::parse_from_str(timestamp, FILENAME_TS_FORMAT).ok()?; + let naive = + NaiveDateTime::parse_from_str(timestamp, crate::extension_resources::FILENAME_TS_FORMAT) + .ok()?; Some(DateTime::from_naive_utc_and_offset(naive, Utc)) } diff --git a/codex-rs/memories/write/src/extensions_tests.rs b/codex-rs/memories/write/src/extensions_tests.rs index 60cd187579..e93335e16f 100644 --- a/codex-rs/memories/write/src/extensions_tests.rs +++ b/codex-rs/memories/write/src/extensions_tests.rs @@ -19,8 +19,11 @@ async fn prunes_only_old_resources_from_extensions_with_instructions() { .expect("write chronicle instructions"); let now = DateTime::from_naive_utc_and_offset( - NaiveDateTime::parse_from_str("2026-04-14T12-00-00", FILENAME_TS_FORMAT) - .expect("parse now"), + NaiveDateTime::parse_from_str( + "2026-04-14T12-00-00", + crate::extension_resources::FILENAME_TS_FORMAT, + ) + .expect("parse now"), Utc, ); let old_file = chronicle_resources.join("2026-04-06T11-59-59-abcd-10min-old.md"); diff --git a/codex-rs/memories/write/src/guard.rs b/codex-rs/memories/write/src/guard.rs index 7deb745170..4d75043f97 100644 --- a/codex-rs/memories/write/src/guard.rs +++ b/codex-rs/memories/write/src/guard.rs @@ -6,8 +6,6 @@ use codex_protocol::protocol::RateLimitWindow; use tracing::info; use tracing::warn; -const CODEX_LIMIT_ID: &str = "codex"; - pub(crate) async fn rate_limits_ok(auth_manager: &AuthManager, config: &Config) -> bool { rate_limits_check(auth_manager, config) .await @@ -32,7 +30,7 @@ async fn rate_limits_check(auth_manager: &AuthManager, config: &Config) -> Optio let snapshot = snapshots .iter() - .find(|s| s.limit_id.as_deref() == Some(CODEX_LIMIT_ID)) + .find(|s| s.limit_id.as_deref() == Some(crate::guard_limits::CODEX_LIMIT_ID)) .or_else(|| snapshots.first())?; let min_remaining_percent = config.memories.min_rate_limit_remaining_percent; diff --git a/codex-rs/memories/write/src/guard_tests.rs b/codex-rs/memories/write/src/guard_tests.rs index 6c22b0681c..139a8773ca 100644 --- a/codex-rs/memories/write/src/guard_tests.rs +++ b/codex-rs/memories/write/src/guard_tests.rs @@ -6,7 +6,7 @@ fn snapshot( secondary_used_percent: Option, ) -> RateLimitSnapshot { RateLimitSnapshot { - limit_id: Some(CODEX_LIMIT_ID.to_string()), + limit_id: Some(crate::guard_limits::CODEX_LIMIT_ID.to_string()), limit_name: None, primary: primary_used_percent.map(window), secondary: secondary_used_percent.map(window), diff --git a/codex-rs/memories/write/src/lib.rs b/codex-rs/memories/write/src/lib.rs index 63f1565710..6764ebf5c6 100644 --- a/codex-rs/memories/write/src/lib.rs +++ b/codex-rs/memories/write/src/lib.rs @@ -29,20 +29,6 @@ pub use storage::rebuild_raw_memories_file_from_memories; pub use storage::rollout_summary_file_stem; pub use storage::sync_rollout_summaries_from_memories; -/// Prompt used for phase 1 extraction. -pub const STAGE_ONE_PROMPT: &str = include_str!("../templates/memories/stage_one_system.md"); - -/// Fallback stage-1 rollout truncation limit (tokens) when model metadata -/// does not include a valid context window. -pub const DEFAULT_STAGE_ONE_ROLLOUT_TOKEN_LIMIT: usize = 150_000; - -/// Portion of the model effective input window reserved for the stage-1 -/// rollout input. -/// -/// Keeping this below 100% leaves room for system instructions, prompt framing, -/// and model output. -pub const STAGE_ONE_CONTEXT_WINDOW_PERCENT: i64 = 70; - #[cfg(test)] mod startup_tests; @@ -52,6 +38,83 @@ mod artifacts { pub(super) const RAW_MEMORIES_FILENAME: &str = "raw_memories.md"; } +mod extension_resources { + pub(super) const FILENAME_TS_FORMAT: &str = "%Y-%m-%dT%H-%M-%S"; + pub(super) const RETENTION_DAYS: i64 = 7; +} + +mod guard_limits { + pub(super) const CODEX_LIMIT_ID: &str = "codex"; +} + +mod prompt_blocks { + pub(super) const EXTENSIONS_FOLDER_STRUCTURE: &str = r#" +Memory extensions (under {{ memory_extensions_root }}/): + +- /instructions.md + - Source-specific guidance for interpreting additional memory signals. If an + extension folder exists, you must read its instructions.md to determine how to use this memory + source. + +If the user has any memory extensions, you MUST read the instructions for each extension to +determine how to use the memory source. If the workspace diff shows deleted extension resource files, +remove stale memories derived only from those resources. If it has no extension folders, continue +with the standard memory inputs only. +"#; + + pub(super) const EXTENSIONS_PRIMARY_INPUTS: &str = r#" +Optional source-specific inputs: +Under `{{ memory_extensions_root }}/`: + +- `/instructions.md` + - If extension folders exist, read each instructions.md first and follow it when interpreting + that extension's memory source. + +If the workspace diff shows deleted memory extension resources, use that extension-specific deletion +signal to remove stale memories derived only from those resources. +"#; +} + +mod stage_one { + pub(super) const MODEL: &str = "gpt-5.4-mini"; + pub(super) const REASONING_EFFORT: codex_protocol::openai_models::ReasoningEffort = + codex_protocol::openai_models::ReasoningEffort::Low; + pub(super) const CONCURRENCY_LIMIT: usize = 8; + pub(super) const JOB_LEASE_SECONDS: i64 = 3_600; + pub(super) const JOB_RETRY_DELAY_SECONDS: i64 = 3_600; + pub(super) const THREAD_SCAN_LIMIT: usize = 5_000; + pub(super) const PRUNE_BATCH_SIZE: usize = 200; + + /// Prompt used for phase 1 extraction. + pub(super) const PROMPT: &str = include_str!("../templates/memories/stage_one_system.md"); + + /// Fallback stage-1 rollout truncation limit (tokens) when model metadata + /// does not include a valid context window. + pub(super) const DEFAULT_ROLLOUT_TOKEN_LIMIT: usize = 150_000; + + /// Portion of the model effective input window reserved for the stage-1 + /// rollout input. + /// + /// Keeping this below 100% leaves room for system instructions, prompt framing, + /// and model output. + pub(super) const CONTEXT_WINDOW_PERCENT: i64 = 70; +} + +mod stage_two { + pub(super) const MODEL: &str = "gpt-5.4"; + pub(super) const REASONING_EFFORT: codex_protocol::openai_models::ReasoningEffort = + codex_protocol::openai_models::ReasoningEffort::Medium; + pub(super) const JOB_LEASE_SECONDS: i64 = 3_600; + pub(super) const JOB_RETRY_DELAY_SECONDS: i64 = 3_600; + pub(super) const JOB_HEARTBEAT_SECONDS: u64 = 90; +} + +mod workspace_diff { + /// Generated diff file the Phase 2 consolidation agent reads before editing memories. + pub(super) const FILENAME: &str = "phase2_workspace_diff.md"; + pub(super) const MAX_BYTES: usize = 4 * 1024 * 1024; +} + pub fn memory_root(codex_home: &AbsolutePathBuf) -> AbsolutePathBuf { codex_home.join("memories") } diff --git a/codex-rs/memories/write/src/phase1.rs b/codex-rs/memories/write/src/phase1.rs index a3a85b8e8b..9c6c2561de 100644 --- a/codex-rs/memories/write/src/phase1.rs +++ b/codex-rs/memories/write/src/phase1.rs @@ -1,4 +1,3 @@ -use crate::STAGE_ONE_PROMPT; use crate::build_stage_one_input_message; use crate::metrics::MEMORY_PHASE_ONE_E2E_MS; use crate::metrics::MEMORY_PHASE_ONE_JOBS; @@ -14,7 +13,6 @@ use codex_protocol::error::CodexErr; use codex_protocol::models::BaseInstructions; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; -use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::TokenUsage; use codex_rollout::INTERACTIVE_SESSION_SOURCES; @@ -29,14 +27,6 @@ use std::sync::Arc; use tracing::info; use tracing::warn; -const MODEL: &str = "gpt-5.4-mini"; -const REASONING_EFFORT: ReasoningEffort = ReasoningEffort::Low; -const CONCURRENCY_LIMIT: usize = 8; -const JOB_LEASE_SECONDS: i64 = 3_600; -const JOB_RETRY_DELAY_SECONDS: i64 = 3_600; -const THREAD_SCAN_LIMIT: usize = 5_000; -const PRUNE_BATCH_SIZE: usize = 200; - struct JobResult { outcome: JobOutcome, token_usage: Option, @@ -122,7 +112,7 @@ pub async fn prune(context: &MemoryStartupContext, config: &Config) { if let Some(db) = context.state_db() { let max_unused_days = config.memories.max_unused_days; match db - .prune_stage1_outputs_for_retention(max_unused_days, PRUNE_BATCH_SIZE) + .prune_stage1_outputs_for_retention(max_unused_days, crate::stage_one::PRUNE_BATCH_SIZE) .await { Ok(pruned) => { @@ -174,12 +164,12 @@ async fn claim_startup_jobs( .claim_stage1_jobs_for_startup( context.thread_id(), codex_state::Stage1StartupClaimParams { - scan_limit: THREAD_SCAN_LIMIT, + scan_limit: crate::stage_one::THREAD_SCAN_LIMIT, max_claimed: memories_config.max_rollouts_per_startup, max_age_days: memories_config.max_rollout_age_days, min_rollout_idle_hours: memories_config.min_rollout_idle_hours, allowed_sources: allowed_sources.as_slice(), - lease_seconds: JOB_LEASE_SECONDS, + lease_seconds: crate::stage_one::JOB_LEASE_SECONDS, }, ) .await @@ -200,9 +190,9 @@ async fn build_request_context( .memories .extract_model .clone() - .unwrap_or(MODEL.to_string()); + .unwrap_or(crate::stage_one::MODEL.to_string()); context - .stage_one_request_context(config, &model_name, REASONING_EFFORT) + .stage_one_request_context(config, &model_name, crate::stage_one::REASONING_EFFORT) .await } @@ -221,7 +211,7 @@ async fn run_jobs( job::run(context.as_ref(), config.as_ref(), claim, &stage_one_context).await } }) - .buffer_unordered(CONCURRENCY_LIMIT) + .buffer_unordered(crate::stage_one::CONCURRENCY_LIMIT) .collect::>() .await } @@ -310,7 +300,7 @@ mod job { phase: None, }]; prompt.base_instructions = BaseInstructions { - text: STAGE_ONE_PROMPT.to_string(), + text: crate::stage_one::PROMPT.to_string(), }; prompt.output_schema = Some(output_schema()); prompt.output_schema_strict = true; @@ -343,7 +333,7 @@ mod job { thread_id, ownership_token, reason, - JOB_RETRY_DELAY_SECONDS, + crate::stage_one::JOB_RETRY_DELAY_SECONDS, ) .await; } diff --git a/codex-rs/memories/write/src/phase2.rs b/codex-rs/memories/write/src/phase2.rs index 14954c49e4..7a3841f8c3 100644 --- a/codex-rs/memories/write/src/phase2.rs +++ b/codex-rs/memories/write/src/phase2.rs @@ -28,12 +28,6 @@ use std::collections::HashMap; use std::path::Path; use std::sync::Arc; use std::time::Duration; -const MODEL: &str = "gpt-5.4"; -const REASONING_EFFORT: codex_protocol::openai_models::ReasoningEffort = - codex_protocol::openai_models::ReasoningEffort::Medium; -const JOB_LEASE_SECONDS: i64 = 3_600; -const JOB_RETRY_DELAY_SECONDS: i64 = 3_600; -const JOB_HEARTBEAT_SECONDS: u64 = 90; #[derive(Debug, Clone, Default)] struct Claim { @@ -223,7 +217,7 @@ mod job { db: &StateRuntime, ) -> Result { let claim = db - .try_claim_global_phase2_job(context.thread_id(), JOB_LEASE_SECONDS) + .try_claim_global_phase2_job(context.thread_id(), crate::stage_two::JOB_LEASE_SECONDS) .await .map_err(|e| { tracing::error!("failed to claim job: {e}"); @@ -261,15 +255,19 @@ mod job { ) { context.counter(MEMORY_PHASE_TWO_JOBS, /*inc*/ 1, &[("status", reason)]); if matches!( - db.mark_global_phase2_job_failed(&claim.token, reason, JOB_RETRY_DELAY_SECONDS,) - .await, + db.mark_global_phase2_job_failed( + &claim.token, + reason, + crate::stage_two::JOB_RETRY_DELAY_SECONDS, + ) + .await, Ok(false) ) { let _ = db .mark_global_phase2_job_failed_if_unowned( &claim.token, reason, - JOB_RETRY_DELAY_SECONDS, + crate::stage_two::JOB_RETRY_DELAY_SECONDS, ) .await; } @@ -336,9 +334,9 @@ mod agent { .memories .consolidation_model .clone() - .unwrap_or(MODEL.to_string()), + .unwrap_or(crate::stage_two::MODEL.to_string()), ); - agent_config.model_reasoning_effort = Some(REASONING_EFFORT); + agent_config.model_reasoning_effort = Some(crate::stage_two::REASONING_EFFORT); Some(agent_config) } @@ -384,7 +382,10 @@ mod agent { } // Do not reset the workspace baseline if we lost the lock. let still_owns_lock = match db - .heartbeat_global_phase2_job(&claim.token, JOB_LEASE_SECONDS) + .heartbeat_global_phase2_job( + &claim.token, + crate::stage_two::JOB_LEASE_SECONDS, + ) .await .inspect_err(|err| { tracing::error!( @@ -448,7 +449,7 @@ mod agent { thread: &codex_core::CodexThread, ) -> AgentStatus { let mut heartbeat_interval = - tokio::time::interval(Duration::from_secs(JOB_HEARTBEAT_SECONDS)); + tokio::time::interval(Duration::from_secs(crate::stage_two::JOB_HEARTBEAT_SECONDS)); heartbeat_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); let mut status_poll_interval = tokio::time::interval(Duration::from_secs(1)); status_poll_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); @@ -480,7 +481,7 @@ mod agent { match db .heartbeat_global_phase2_job( &token, - JOB_LEASE_SECONDS, + crate::stage_two::JOB_LEASE_SECONDS, ) .await { diff --git a/codex-rs/memories/write/src/prompts.rs b/codex-rs/memories/write/src/prompts.rs index 4f607f8219..e0019f610f 100644 --- a/codex-rs/memories/write/src/prompts.rs +++ b/codex-rs/memories/write/src/prompts.rs @@ -1,7 +1,4 @@ -use crate::DEFAULT_STAGE_ONE_ROLLOUT_TOKEN_LIMIT; -use crate::STAGE_ONE_CONTEXT_WINDOW_PERCENT; use crate::memory_extensions_root; -use crate::workspace::WORKSPACE_DIFF_FILENAME; use codex_protocol::openai_models::ModelInfo; use codex_utils_output_truncation::TruncationPolicy; use codex_utils_output_truncation::truncate_text; @@ -24,13 +21,13 @@ static STAGE_ONE_INPUT_TEMPLATE: LazyLock