diff --git a/.markdownlint-cli2.yaml b/.markdownlint-cli2.yaml new file mode 100644 index 0000000000..15b472c61c --- /dev/null +++ b/.markdownlint-cli2.yaml @@ -0,0 +1,6 @@ +config: + MD013: + line_length: 100 + +globs: + - "docs/tui-chat-composer.md" diff --git a/AGENTS.md b/AGENTS.md index 5c0a6db637..9c14089e5f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,7 @@ In the codex-rs folder where the rust code lives: - Use method references over closures when possible per https://rust-lang.github.io/rust-clippy/master/index.html#redundant_closure_for_method_calls - When writing tests, prefer comparing the equality of entire objects over fields one by one. - When making a change that adds or changes an API, ensure that the documentation in the `docs/` folder is up to date if applicable. +- If you change `ConfigToml` or nested config types, run `just write-config-schema` to update `codex-rs/core/config.schema.json`. Run `just fmt` (in `codex-rs` directory) automatically after making Rust code changes; do not ask for approval to run it. Before finalizing a change to `codex-rs`, run `just fix -p ` (in `codex-rs` directory) to fix any linter issues in the code. Prefer scoping with `-p` to avoid slow workspace‑wide Clippy builds; only run `just fix` without `-p` if you changed shared crates. Additionally, run the tests: diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index ac103d30b9..5a89f1a686 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -409,8 +409,8 @@ "chrono_0.4.42": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.0\"},{\"features\":[\"fallback\"],\"name\":\"iana-time-zone\",\"optional\":true,\"req\":\"^0.1.45\",\"target\":\"cfg(unix)\"},{\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2\"},{\"name\":\"pure-rust-locales\",\"optional\":true,\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"rkyv\",\"optional\":true,\"req\":\"^0.7.43\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.99\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"similar-asserts\",\"req\":\"^1.6.1\"},{\"name\":\"wasm-bindgen\",\"optional\":true,\"req\":\"^0.2\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"windows-bindgen\",\"req\":\"^0.63\",\"target\":\"cfg(windows)\"},{\"name\":\"windows-link\",\"optional\":true,\"req\":\"^0.2\",\"target\":\"cfg(windows)\"}],\"features\":{\"__internal_bench\":[],\"alloc\":[],\"clock\":[\"winapi\",\"iana-time-zone\",\"now\"],\"core-error\":[],\"default\":[\"clock\",\"std\",\"oldtime\",\"wasmbind\"],\"libc\":[],\"now\":[\"std\"],\"oldtime\":[],\"rkyv\":[\"dep:rkyv\",\"rkyv/size_32\"],\"rkyv-16\":[\"dep:rkyv\",\"rkyv?/size_16\"],\"rkyv-32\":[\"dep:rkyv\",\"rkyv?/size_32\"],\"rkyv-64\":[\"dep:rkyv\",\"rkyv?/size_64\"],\"rkyv-validation\":[\"rkyv?/validation\"],\"std\":[\"alloc\"],\"unstable-locales\":[\"pure-rust-locales\"],\"wasmbind\":[\"wasm-bindgen\",\"js-sys\"],\"winapi\":[\"windows-link\"]}}", "chunked_transfer_1.5.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"}],\"features\":{}}", "cipher_0.4.4": "{\"dependencies\":[{\"name\":\"blobby\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"crypto-common\",\"req\":\"^0.1.6\"},{\"name\":\"inout\",\"req\":\"^0.1\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.5\"}],\"features\":{\"alloc\":[],\"block-padding\":[\"inout/block-padding\"],\"dev\":[\"blobby\"],\"rand_core\":[\"crypto-common/rand_core\"],\"std\":[\"alloc\",\"crypto-common/std\",\"inout/std\"]}}", - "clap_4.5.53": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"automod\",\"req\":\"^1.0.14\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"clap-cargo\",\"req\":\"^0.15.0\"},{\"default_features\":false,\"name\":\"clap_builder\",\"req\":\"=4.5.53\"},{\"name\":\"clap_derive\",\"optional\":true,\"req\":\"=4.5.49\"},{\"kind\":\"dev\",\"name\":\"jiff\",\"req\":\"^0.2.3\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.15\"},{\"kind\":\"dev\",\"name\":\"semver\",\"req\":\"^1.0.26\"},{\"kind\":\"dev\",\"name\":\"shlex\",\"req\":\"^1.3.0\"},{\"features\":[\"term-svg\"],\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.16\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.91\"},{\"default_features\":false,\"features\":[\"color-auto\",\"diff\",\"examples\"],\"kind\":\"dev\",\"name\":\"trycmd\",\"req\":\"^0.15.3\"}],\"features\":{\"cargo\":[\"clap_builder/cargo\"],\"color\":[\"clap_builder/color\"],\"debug\":[\"clap_builder/debug\",\"clap_derive?/debug\"],\"default\":[\"std\",\"color\",\"help\",\"usage\",\"error-context\",\"suggestions\"],\"deprecated\":[\"clap_builder/deprecated\",\"clap_derive?/deprecated\"],\"derive\":[\"dep:clap_derive\"],\"env\":[\"clap_builder/env\"],\"error-context\":[\"clap_builder/error-context\"],\"help\":[\"clap_builder/help\"],\"std\":[\"clap_builder/std\"],\"string\":[\"clap_builder/string\"],\"suggestions\":[\"clap_builder/suggestions\"],\"unicode\":[\"clap_builder/unicode\"],\"unstable-derive-ui-tests\":[],\"unstable-doc\":[\"clap_builder/unstable-doc\",\"derive\"],\"unstable-ext\":[\"clap_builder/unstable-ext\"],\"unstable-markdown\":[\"clap_derive/unstable-markdown\"],\"unstable-styles\":[\"clap_builder/unstable-styles\"],\"unstable-v5\":[\"clap_builder/unstable-v5\",\"clap_derive?/unstable-v5\",\"deprecated\"],\"usage\":[\"clap_builder/usage\"],\"wrap_help\":[\"clap_builder/wrap_help\"]}}", - "clap_builder_4.5.53": "{\"dependencies\":[{\"name\":\"anstream\",\"optional\":true,\"req\":\"^0.6.7\"},{\"name\":\"anstyle\",\"req\":\"^1.0.8\"},{\"name\":\"backtrace\",\"optional\":true,\"req\":\"^0.3.73\"},{\"name\":\"clap_lex\",\"req\":\"^0.7.4\"},{\"kind\":\"dev\",\"name\":\"color-print\",\"req\":\"^0.3.6\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.16\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1.0\"},{\"name\":\"strsim\",\"optional\":true,\"req\":\"^0.11.0\"},{\"name\":\"terminal_size\",\"optional\":true,\"req\":\"^0.4.0\"},{\"kind\":\"dev\",\"name\":\"unic-emoji-char\",\"req\":\"^0.9.0\"},{\"name\":\"unicase\",\"optional\":true,\"req\":\"^2.6.0\"},{\"name\":\"unicode-width\",\"optional\":true,\"req\":\"^0.2.0\"}],\"features\":{\"cargo\":[],\"color\":[\"dep:anstream\"],\"debug\":[\"dep:backtrace\"],\"default\":[\"std\",\"color\",\"help\",\"usage\",\"error-context\",\"suggestions\"],\"deprecated\":[],\"env\":[],\"error-context\":[],\"help\":[],\"std\":[\"anstyle/std\"],\"string\":[],\"suggestions\":[\"dep:strsim\",\"error-context\"],\"unicode\":[\"dep:unicode-width\",\"dep:unicase\"],\"unstable-doc\":[\"cargo\",\"wrap_help\",\"env\",\"unicode\",\"string\",\"unstable-ext\"],\"unstable-ext\":[],\"unstable-styles\":[\"color\"],\"unstable-v5\":[\"deprecated\"],\"usage\":[],\"wrap_help\":[\"help\",\"dep:terminal_size\"]}}", + "clap_4.5.54": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"automod\",\"req\":\"^1.0.14\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"clap-cargo\",\"req\":\"^0.15.0\"},{\"default_features\":false,\"name\":\"clap_builder\",\"req\":\"=4.5.54\"},{\"name\":\"clap_derive\",\"optional\":true,\"req\":\"=4.5.49\"},{\"kind\":\"dev\",\"name\":\"jiff\",\"req\":\"^0.2.3\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.15\"},{\"kind\":\"dev\",\"name\":\"semver\",\"req\":\"^1.0.26\"},{\"kind\":\"dev\",\"name\":\"shlex\",\"req\":\"^1.3.0\"},{\"features\":[\"term-svg\"],\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.16\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.91\"},{\"default_features\":false,\"features\":[\"color-auto\",\"diff\",\"examples\"],\"kind\":\"dev\",\"name\":\"trycmd\",\"req\":\"^0.15.3\"}],\"features\":{\"cargo\":[\"clap_builder/cargo\"],\"color\":[\"clap_builder/color\"],\"debug\":[\"clap_builder/debug\",\"clap_derive?/debug\"],\"default\":[\"std\",\"color\",\"help\",\"usage\",\"error-context\",\"suggestions\"],\"deprecated\":[\"clap_builder/deprecated\",\"clap_derive?/deprecated\"],\"derive\":[\"dep:clap_derive\"],\"env\":[\"clap_builder/env\"],\"error-context\":[\"clap_builder/error-context\"],\"help\":[\"clap_builder/help\"],\"std\":[\"clap_builder/std\"],\"string\":[\"clap_builder/string\"],\"suggestions\":[\"clap_builder/suggestions\"],\"unicode\":[\"clap_builder/unicode\"],\"unstable-derive-ui-tests\":[],\"unstable-doc\":[\"clap_builder/unstable-doc\",\"derive\"],\"unstable-ext\":[\"clap_builder/unstable-ext\"],\"unstable-markdown\":[\"clap_derive/unstable-markdown\"],\"unstable-styles\":[\"clap_builder/unstable-styles\"],\"unstable-v5\":[\"clap_builder/unstable-v5\",\"clap_derive?/unstable-v5\",\"deprecated\"],\"usage\":[\"clap_builder/usage\"],\"wrap_help\":[\"clap_builder/wrap_help\"]}}", + "clap_builder_4.5.54": "{\"dependencies\":[{\"name\":\"anstream\",\"optional\":true,\"req\":\"^0.6.7\"},{\"name\":\"anstyle\",\"req\":\"^1.0.8\"},{\"name\":\"backtrace\",\"optional\":true,\"req\":\"^0.3.73\"},{\"name\":\"clap_lex\",\"req\":\"^0.7.4\"},{\"kind\":\"dev\",\"name\":\"color-print\",\"req\":\"^0.3.6\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.16\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1.0\"},{\"name\":\"strsim\",\"optional\":true,\"req\":\"^0.11.0\"},{\"name\":\"terminal_size\",\"optional\":true,\"req\":\"^0.4.0\"},{\"kind\":\"dev\",\"name\":\"unic-emoji-char\",\"req\":\"^0.9.0\"},{\"name\":\"unicase\",\"optional\":true,\"req\":\"^2.6.0\"},{\"name\":\"unicode-width\",\"optional\":true,\"req\":\"^0.2.0\"}],\"features\":{\"cargo\":[],\"color\":[\"dep:anstream\"],\"debug\":[\"dep:backtrace\"],\"default\":[\"std\",\"color\",\"help\",\"usage\",\"error-context\",\"suggestions\"],\"deprecated\":[],\"env\":[],\"error-context\":[],\"help\":[],\"std\":[\"anstyle/std\"],\"string\":[],\"suggestions\":[\"dep:strsim\",\"error-context\"],\"unicode\":[\"dep:unicode-width\",\"dep:unicase\"],\"unstable-doc\":[\"cargo\",\"wrap_help\",\"env\",\"unicode\",\"string\",\"unstable-ext\"],\"unstable-ext\":[],\"unstable-styles\":[\"color\"],\"unstable-v5\":[\"deprecated\"],\"usage\":[],\"wrap_help\":[\"help\",\"dep:terminal_size\"]}}", "clap_complete_4.5.64": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"automod\",\"req\":\"^1.0.14\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"clap\",\"req\":\"^4.5.20\"},{\"default_features\":false,\"features\":[\"std\",\"derive\",\"help\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4.5.20\"},{\"name\":\"clap_lex\",\"optional\":true,\"req\":\"^0.7.0\"},{\"name\":\"completest\",\"optional\":true,\"req\":\"^0.4.2\"},{\"name\":\"completest-pty\",\"optional\":true,\"req\":\"^0.5.5\"},{\"name\":\"is_executable\",\"optional\":true,\"req\":\"^1.0.1\"},{\"name\":\"shlex\",\"optional\":true,\"req\":\"^1.3.0\"},{\"features\":[\"diff\",\"dir\",\"examples\"],\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.0\"},{\"default_features\":false,\"features\":[\"color-auto\",\"diff\",\"examples\"],\"kind\":\"dev\",\"name\":\"trycmd\",\"req\":\"^0.15.1\"}],\"features\":{\"debug\":[\"clap/debug\"],\"default\":[],\"unstable-doc\":[\"unstable-dynamic\"],\"unstable-dynamic\":[\"dep:clap_lex\",\"dep:shlex\",\"dep:is_executable\",\"clap/unstable-ext\"],\"unstable-shell-tests\":[\"dep:completest\",\"dep:completest-pty\"]}}", "clap_derive_4.5.49": "{\"dependencies\":[{\"name\":\"anstyle\",\"optional\":true,\"req\":\"^1.0.10\"},{\"name\":\"heck\",\"req\":\"^0.5.0\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.69\"},{\"default_features\":false,\"name\":\"pulldown-cmark\",\"optional\":true,\"req\":\"^0.13.0\"},{\"name\":\"quote\",\"req\":\"^1.0.9\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2.0.8\"}],\"features\":{\"debug\":[],\"default\":[],\"deprecated\":[],\"raw-deprecated\":[\"deprecated\"],\"unstable-markdown\":[\"dep:pulldown-cmark\",\"dep:anstyle\"],\"unstable-v5\":[\"deprecated\"]}}", "clap_lex_0.7.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"automod\",\"req\":\"^1.0.14\"}],\"features\":{}}", @@ -495,6 +495,7 @@ "enumflags2_derive_0.7.12": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"parsing\",\"printing\",\"derive\",\"proc-macro\"],\"name\":\"syn\",\"req\":\"^2.0\"}],\"features\":{}}", "env-flags_0.1.1": "{\"dependencies\":[],\"features\":{}}", "env_filter_0.1.3": "{\"dependencies\":[{\"features\":[\"std\"],\"name\":\"log\",\"req\":\"^0.4.8\"},{\"default_features\":false,\"features\":[\"std\",\"perf\"],\"name\":\"regex\",\"optional\":true,\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6\"}],\"features\":{\"default\":[\"regex\"],\"regex\":[\"dep:regex\"]}}", + "env_home_0.1.0": "{\"dependencies\":[],\"features\":{}}", "env_logger_0.11.8": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"wincon\"],\"name\":\"anstream\",\"optional\":true,\"req\":\"^0.6.11\"},{\"name\":\"anstyle\",\"optional\":true,\"req\":\"^1.0.6\"},{\"default_features\":false,\"name\":\"env_filter\",\"req\":\"^0.1.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"jiff\",\"optional\":true,\"req\":\"^0.2.3\"},{\"features\":[\"std\"],\"name\":\"log\",\"req\":\"^0.4.21\"}],\"features\":{\"auto-color\":[\"color\",\"anstream/auto\"],\"color\":[\"dep:anstream\",\"dep:anstyle\"],\"default\":[\"auto-color\",\"humantime\",\"regex\"],\"humantime\":[\"dep:jiff\"],\"kv\":[\"log/kv\"],\"regex\":[\"env_filter/regex\"],\"unstable-kv\":[\"kv\"]}}", "equivalent_1.0.2": "{\"dependencies\":[],\"features\":{}}", "erased-serde_0.3.31": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.13\"},{\"default_features\":false,\"name\":\"serde\",\"req\":\"^1.0.166\"},{\"kind\":\"dev\",\"name\":\"serde_cbor\",\"req\":\"^0.11.2\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.166\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.99\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.83\"}],\"features\":{\"alloc\":[\"serde/alloc\"],\"default\":[\"std\"],\"std\":[\"serde/std\"],\"unstable-debug\":[]}}", @@ -905,7 +906,7 @@ "tokio-rustls_0.26.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"argh\",\"req\":\"^0.1.1\"},{\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.1\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.1\"},{\"features\":[\"pem\"],\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.13\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"req\":\"^0.23.22\"},{\"name\":\"tokio\",\"req\":\"^1.0\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"webpki-roots\",\"req\":\"^0.26\"}],\"features\":{\"aws-lc-rs\":[\"aws_lc_rs\"],\"aws_lc_rs\":[\"rustls/aws_lc_rs\"],\"default\":[\"logging\",\"tls12\",\"aws_lc_rs\"],\"early-data\":[],\"fips\":[\"rustls/fips\"],\"logging\":[\"rustls/logging\"],\"ring\":[\"rustls/ring\"],\"tls12\":[\"rustls/tls12\"]}}", "tokio-stream_0.1.18": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-stream\",\"req\":\"^0.3\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"name\":\"futures-core\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"parking_lot\",\"req\":\"^0.12.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"req\":\"^1.15.0\"},{\"features\":[\"full\",\"test-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.2.0\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"},{\"name\":\"tokio-util\",\"optional\":true,\"req\":\"^0.7.0\"}],\"features\":{\"default\":[\"time\"],\"fs\":[\"tokio/fs\"],\"full\":[\"time\",\"net\",\"io-util\",\"fs\",\"sync\",\"signal\"],\"io-util\":[\"tokio/io-util\"],\"net\":[\"tokio/net\"],\"signal\":[\"tokio/signal\"],\"sync\":[\"tokio/sync\",\"tokio-util\"],\"time\":[\"tokio/time\"]}}", "tokio-test_0.4.4": "{\"dependencies\":[{\"name\":\"async-stream\",\"req\":\"^0.3.3\"},{\"name\":\"bytes\",\"req\":\"^1.0.0\"},{\"name\":\"futures-core\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.0\"},{\"features\":[\"rt\",\"sync\",\"time\",\"test-util\"],\"name\":\"tokio\",\"req\":\"^1.2.0\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.2.0\"},{\"name\":\"tokio-stream\",\"req\":\"^0.1.1\"}],\"features\":{}}", - "tokio-util_0.7.16": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-stream\",\"req\":\"^0.3.0\"},{\"name\":\"bytes\",\"req\":\"^1.5.0\"},{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.0\"},{\"name\":\"futures-core\",\"req\":\"^0.3.0\"},{\"name\":\"futures-io\",\"optional\":true,\"req\":\"^0.3.0\"},{\"name\":\"futures-sink\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"futures-test\",\"req\":\"^0.3.5\"},{\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3.0\"},{\"default_features\":false,\"name\":\"hashbrown\",\"optional\":true,\"req\":\"^0.15.0\"},{\"kind\":\"dev\",\"name\":\"parking_lot\",\"req\":\"^0.12.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\"},{\"name\":\"slab\",\"optional\":true,\"req\":\"^0.4.4\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1.0\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"req\":\"^1.28.0\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.29\"}],\"features\":{\"__docs_rs\":[\"futures-util\"],\"codec\":[],\"compat\":[\"futures-io\"],\"default\":[],\"full\":[\"codec\",\"compat\",\"io-util\",\"time\",\"net\",\"rt\",\"join-map\"],\"io\":[],\"io-util\":[\"io\",\"tokio/rt\",\"tokio/io-util\"],\"join-map\":[\"rt\",\"hashbrown\"],\"net\":[\"tokio/net\"],\"rt\":[\"tokio/rt\",\"tokio/sync\",\"futures-util\"],\"time\":[\"tokio/time\",\"slab\"]}}", + "tokio-util_0.7.18": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-stream\",\"req\":\"^0.3.0\"},{\"name\":\"bytes\",\"req\":\"^1.5.0\"},{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.0\"},{\"name\":\"futures-core\",\"req\":\"^0.3.0\"},{\"name\":\"futures-io\",\"optional\":true,\"req\":\"^0.3.0\"},{\"name\":\"futures-sink\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"futures-test\",\"req\":\"^0.3.5\"},{\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3.0\"},{\"default_features\":false,\"name\":\"hashbrown\",\"optional\":true,\"req\":\"^0.15.0\"},{\"features\":[\"futures\",\"checkpoint\"],\"kind\":\"dev\",\"name\":\"loom\",\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"kind\":\"dev\",\"name\":\"parking_lot\",\"req\":\"^0.12.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\"},{\"name\":\"slab\",\"optional\":true,\"req\":\"^0.4.4\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1.0\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"req\":\"^1.44.0\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.29\"}],\"features\":{\"__docs_rs\":[\"futures-util\"],\"codec\":[],\"compat\":[\"futures-io\"],\"default\":[],\"full\":[\"codec\",\"compat\",\"io-util\",\"time\",\"net\",\"rt\",\"join-map\"],\"io\":[],\"io-util\":[\"io\",\"tokio/rt\",\"tokio/io-util\"],\"join-map\":[\"rt\",\"hashbrown\"],\"net\":[\"tokio/net\"],\"rt\":[\"tokio/rt\",\"tokio/sync\",\"futures-util\"],\"time\":[\"tokio/time\",\"slab\"]}}", "tokio_1.48.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-stream\",\"req\":\"^0.3\"},{\"name\":\"backtrace\",\"optional\":true,\"req\":\"^0.3.58\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1.2.1\"},{\"features\":[\"async-await\"],\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"futures-concurrency\",\"req\":\"^7.6.3\"},{\"default_features\":false,\"name\":\"io-uring\",\"optional\":true,\"req\":\"^0.7.6\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.168\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.168\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.168\",\"target\":\"cfg(unix)\"},{\"features\":[\"futures\",\"checkpoint\"],\"kind\":\"dev\",\"name\":\"loom\",\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"default_features\":false,\"name\":\"mio\",\"optional\":true,\"req\":\"^1.0.1\"},{\"default_features\":false,\"features\":[\"os-poll\",\"os-ext\"],\"name\":\"mio\",\"optional\":true,\"req\":\"^1.0.1\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"features\":[\"tokio\"],\"kind\":\"dev\",\"name\":\"mio-aio\",\"req\":\"^1\",\"target\":\"cfg(target_os = \\\"freebsd\\\")\"},{\"kind\":\"dev\",\"name\":\"mockall\",\"req\":\"^0.13.0\"},{\"default_features\":false,\"features\":[\"aio\",\"fs\",\"socket\"],\"kind\":\"dev\",\"name\":\"nix\",\"req\":\"^0.29.0\",\"target\":\"cfg(unix)\"},{\"name\":\"parking_lot\",\"optional\":true,\"req\":\"^0.12.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\",\"target\":\"cfg(not(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\")))\"},{\"name\":\"signal-hook-registry\",\"optional\":true,\"req\":\"^1.1.1\",\"target\":\"cfg(unix)\"},{\"name\":\"slab\",\"optional\":true,\"req\":\"^0.4.9\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"features\":[\"all\"],\"name\":\"socket2\",\"optional\":true,\"req\":\"^0.6.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"socket2\",\"req\":\"^0.6.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"name\":\"tokio-macros\",\"optional\":true,\"req\":\"~2.6.0\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4.0\"},{\"features\":[\"rt\"],\"kind\":\"dev\",\"name\":\"tokio-util\",\"req\":\"^0.7\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.29\",\"target\":\"cfg(tokio_unstable)\"},{\"kind\":\"dev\",\"name\":\"tracing-mock\",\"req\":\"=0.1.0-beta.1\",\"target\":\"cfg(all(tokio_unstable, target_has_atomic = \\\"64\\\"))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.0\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", not(target_os = \\\"wasi\\\")))\"},{\"name\":\"windows-sys\",\"optional\":true,\"req\":\"^0.61\",\"target\":\"cfg(windows)\"},{\"features\":[\"Win32_Foundation\",\"Win32_Security_Authorization\"],\"kind\":\"dev\",\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[],\"fs\":[],\"full\":[\"fs\",\"io-util\",\"io-std\",\"macros\",\"net\",\"parking_lot\",\"process\",\"rt\",\"rt-multi-thread\",\"signal\",\"sync\",\"time\"],\"io-std\":[],\"io-uring\":[\"dep:io-uring\",\"libc\",\"mio/os-poll\",\"mio/os-ext\",\"dep:slab\"],\"io-util\":[\"bytes\"],\"macros\":[\"tokio-macros\"],\"net\":[\"libc\",\"mio/os-poll\",\"mio/os-ext\",\"mio/net\",\"socket2\",\"windows-sys/Win32_Foundation\",\"windows-sys/Win32_Security\",\"windows-sys/Win32_Storage_FileSystem\",\"windows-sys/Win32_System_Pipes\",\"windows-sys/Win32_System_SystemServices\"],\"process\":[\"bytes\",\"libc\",\"mio/os-poll\",\"mio/os-ext\",\"mio/net\",\"signal-hook-registry\",\"windows-sys/Win32_Foundation\",\"windows-sys/Win32_System_Threading\",\"windows-sys/Win32_System_WindowsProgramming\"],\"rt\":[],\"rt-multi-thread\":[\"rt\"],\"signal\":[\"libc\",\"mio/os-poll\",\"mio/net\",\"mio/os-ext\",\"signal-hook-registry\",\"windows-sys/Win32_Foundation\",\"windows-sys/Win32_System_Console\"],\"sync\":[],\"taskdump\":[\"dep:backtrace\"],\"test-util\":[\"rt\",\"sync\",\"time\"],\"time\":[]}}", "toml_0.5.11": "{\"dependencies\":[{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde\",\"req\":\"^1.0.97\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"default\":[],\"preserve_order\":[\"indexmap\"]}}", "toml_0.9.5": "{\"dependencies\":[{\"name\":\"anstream\",\"optional\":true,\"req\":\"^0.6.15\"},{\"name\":\"anstyle\",\"optional\":true,\"req\":\"^1.0.8\"},{\"default_features\":false,\"name\":\"foldhash\",\"optional\":true,\"req\":\"^0.1.5\"},{\"default_features\":false,\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.3.0\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.14.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.145\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.199\"},{\"kind\":\"dev\",\"name\":\"serde-untagged\",\"req\":\"^0.1.7\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.116\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde_spanned\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.0\"},{\"kind\":\"dev\",\"name\":\"toml-test-data\",\"req\":\"^2.3.0\"},{\"features\":[\"snapshot\"],\"kind\":\"dev\",\"name\":\"toml-test-harness\",\"req\":\"^1.3.2\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"toml_datetime\",\"req\":\"^0.7.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"toml_parser\",\"optional\":true,\"req\":\"^1.0.2\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"toml_writer\",\"optional\":true,\"req\":\"^1.0.2\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.5.0\"},{\"default_features\":false,\"name\":\"winnow\",\"optional\":true,\"req\":\"^0.7.10\"}],\"features\":{\"debug\":[\"std\",\"toml_parser?/debug\",\"dep:anstream\",\"dep:anstyle\"],\"default\":[\"std\",\"serde\",\"parse\",\"display\"],\"display\":[\"dep:toml_writer\"],\"fast_hash\":[\"preserve_order\",\"dep:foldhash\"],\"parse\":[\"dep:toml_parser\",\"dep:winnow\"],\"preserve_order\":[\"dep:indexmap\",\"std\"],\"serde\":[\"dep:serde\",\"toml_datetime/serde\",\"serde_spanned/serde\"],\"std\":[\"indexmap?/std\",\"serde?/std\",\"toml_parser?/std\",\"toml_writer?/std\",\"toml_datetime/std\",\"serde_spanned/std\"],\"unbounded\":[]}}", @@ -936,9 +937,9 @@ "tree-sitter_0.25.10": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"bindgen\",\"optional\":true,\"req\":\"^0.71.1\"},{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.2.10\"},{\"default_features\":false,\"features\":[\"unicode\"],\"name\":\"regex\",\"req\":\"^1.11.1\"},{\"default_features\":false,\"name\":\"regex-syntax\",\"req\":\"^0.8.5\"},{\"features\":[\"preserve_order\"],\"kind\":\"build\",\"name\":\"serde_json\",\"req\":\"^1.0.137\"},{\"name\":\"streaming-iterator\",\"req\":\"^0.1.9\"},{\"name\":\"tree-sitter-language\",\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"cranelift\",\"gc-drc\"],\"name\":\"wasmtime-c-api\",\"optional\":true,\"package\":\"wasmtime-c-api-impl\",\"req\":\"^29.0.1\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"regex/std\",\"regex/perf\",\"regex-syntax/unicode\"],\"wasm\":[\"std\",\"wasmtime-c-api\"]}}", "tree_magic_mini_3.2.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.0\"},{\"name\":\"memchr\",\"req\":\"^2.0\"},{\"name\":\"nom\",\"req\":\"^7.0\"},{\"name\":\"once_cell\",\"req\":\"^1.0\"},{\"name\":\"petgraph\",\"req\":\"^0.6.0\"},{\"name\":\"tree_magic_db\",\"optional\":true,\"req\":\"^3.0\"}],\"features\":{\"with-gpl-data\":[\"dep:tree_magic_db\"]}}", "try-lock_0.2.5": "{\"dependencies\":[],\"features\":{}}", - "ts-rs-macros_11.0.1": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"full\",\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2.0.28\"},{\"name\":\"termcolor\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"no-serde-warnings\":[],\"serde-compat\":[\"termcolor\"]}}", - "ts-rs_11.0.1": "{\"dependencies\":[{\"features\":[\"serde\"],\"name\":\"bigdecimal\",\"optional\":true,\"req\":\">=0.0.13, <0.5\"},{\"name\":\"bson\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4\"},{\"features\":[\"serde\"],\"kind\":\"dev\",\"name\":\"chrono\",\"req\":\"^0.4\"},{\"name\":\"dprint-plugin-typescript\",\"optional\":true,\"req\":\"^0.90\"},{\"name\":\"heapless\",\"optional\":true,\"req\":\">=0.7, <0.9\"},{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"ordered-float\",\"optional\":true,\"req\":\">=3, <6\"},{\"name\":\"semver\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"name\":\"smol_str\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"sync\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.40\"},{\"name\":\"ts-rs-macros\",\"req\":\"=11.0.1\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"bigdecimal-impl\":[\"bigdecimal\"],\"bson-uuid-impl\":[\"bson\"],\"bytes-impl\":[\"bytes\"],\"chrono-impl\":[\"chrono\"],\"default\":[\"serde-compat\"],\"format\":[\"dprint-plugin-typescript\"],\"heapless-impl\":[\"heapless\"],\"import-esm\":[],\"indexmap-impl\":[\"indexmap\"],\"no-serde-warnings\":[\"ts-rs-macros/no-serde-warnings\"],\"ordered-float-impl\":[\"ordered-float\"],\"semver-impl\":[\"semver\"],\"serde-compat\":[\"ts-rs-macros/serde-compat\"],\"serde-json-impl\":[\"serde_json\"],\"smol_str-impl\":[\"smol_str\"],\"tokio-impl\":[\"tokio\"],\"url-impl\":[\"url\"],\"uuid-impl\":[\"uuid\"]}}", - "tui-scrollbar_0.2.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"color-eyre\",\"req\":\"^0.6\"},{\"name\":\"crossterm\",\"optional\":true,\"req\":\"^0.29\"},{\"name\":\"document-features\",\"req\":\"^0.2.11\"},{\"kind\":\"dev\",\"name\":\"ratatui\",\"req\":\"^0.30.0\"},{\"name\":\"ratatui-core\",\"req\":\"^0.1\"}],\"features\":{\"crossterm\":[\"dep:crossterm\"]}}", + "ts-rs-macros_11.1.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"full\",\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2.0.28\"},{\"name\":\"termcolor\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"no-serde-warnings\":[],\"serde-compat\":[\"termcolor\"]}}", + "ts-rs_11.1.0": "{\"dependencies\":[{\"features\":[\"serde\"],\"name\":\"bigdecimal\",\"optional\":true,\"req\":\">=0.0.13, <0.5\"},{\"name\":\"bson\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4\"},{\"features\":[\"serde\"],\"kind\":\"dev\",\"name\":\"chrono\",\"req\":\"^0.4\"},{\"name\":\"dprint-plugin-typescript\",\"optional\":true,\"req\":\"=0.95\"},{\"name\":\"heapless\",\"optional\":true,\"req\":\">=0.7, <0.9\"},{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"ordered-float\",\"optional\":true,\"req\":\">=3, <6\"},{\"name\":\"semver\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"name\":\"smol_str\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"sync\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.40\"},{\"name\":\"ts-rs-macros\",\"req\":\"=11.1.0\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"bigdecimal-impl\":[\"bigdecimal\"],\"bson-uuid-impl\":[\"bson\"],\"bytes-impl\":[\"bytes\"],\"chrono-impl\":[\"chrono\"],\"default\":[\"serde-compat\"],\"format\":[\"dprint-plugin-typescript\"],\"heapless-impl\":[\"heapless\"],\"import-esm\":[],\"indexmap-impl\":[\"indexmap\"],\"no-serde-warnings\":[\"ts-rs-macros/no-serde-warnings\"],\"ordered-float-impl\":[\"ordered-float\"],\"semver-impl\":[\"semver\"],\"serde-compat\":[\"ts-rs-macros/serde-compat\"],\"serde-json-impl\":[\"serde_json\"],\"smol_str-impl\":[\"smol_str\"],\"tokio-impl\":[\"tokio\"],\"url-impl\":[\"url\"],\"uuid-impl\":[\"uuid\"]}}", + "tui-scrollbar_0.2.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"color-eyre\",\"req\":\"^0.6\"},{\"name\":\"crossterm_0_28\",\"optional\":true,\"package\":\"crossterm\",\"req\":\"^0.28\"},{\"name\":\"crossterm_0_29\",\"optional\":true,\"package\":\"crossterm\",\"req\":\"^0.29\"},{\"name\":\"document-features\",\"req\":\"^0.2.11\"},{\"kind\":\"dev\",\"name\":\"ratatui\",\"req\":\"^0.30.0\"},{\"name\":\"ratatui-core\",\"req\":\"^0.1\"}],\"features\":{\"crossterm\":[\"crossterm_0_29\"],\"crossterm_0_28\":[\"dep:crossterm_0_28\"],\"crossterm_0_29\":[\"dep:crossterm_0_29\"],\"default\":[]}}", "typenum_1.18.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"scale-info\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"const-generics\":[],\"force_unix_path_separator\":[],\"i128\":[],\"no_std\":[],\"scale_info\":[\"scale-info/derive\"],\"strict\":[]}}", "uds_windows_1.1.0": "{\"dependencies\":[{\"name\":\"memoffset\",\"req\":\"^0.9.0\"},{\"name\":\"tempfile\",\"req\":\"^3\",\"target\":\"cfg(windows)\"},{\"features\":[\"winsock2\",\"ws2def\",\"minwinbase\",\"ntdef\",\"processthreadsapi\",\"handleapi\",\"ws2tcpip\",\"winbase\"],\"name\":\"winapi\",\"req\":\"^0.3.9\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "uname_0.1.1": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2\"}],\"features\":{}}", @@ -991,7 +992,7 @@ "webpki-root-certs_1.0.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"kind\":\"dev\",\"name\":\"percent-encoding\",\"req\":\"^2.3\"},{\"default_features\":false,\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"ring\",\"req\":\"^0.17.0\"},{\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"webpki\",\"package\":\"rustls-webpki\",\"req\":\"^0.103\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.17.0\"}],\"features\":{}}", "webpki-roots_1.0.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"kind\":\"dev\",\"name\":\"percent-encoding\",\"req\":\"^2.3\"},{\"default_features\":false,\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14\"},{\"kind\":\"dev\",\"name\":\"ring\",\"req\":\"^0.17.0\"},{\"kind\":\"dev\",\"name\":\"rustls\",\"req\":\"^0.23\"},{\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"webpki\",\"package\":\"rustls-webpki\",\"req\":\"^0.103\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.17.0\"},{\"kind\":\"dev\",\"name\":\"yasna\",\"req\":\"^0.5.2\"}],\"features\":{}}", "weezl_0.1.10": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"futures\",\"optional\":true,\"req\":\"^0.3.12\"},{\"default_features\":false,\"features\":[\"macros\",\"io-util\",\"net\",\"rt\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"compat\"],\"kind\":\"dev\",\"name\":\"tokio-util\",\"req\":\"^0.6.2\"}],\"features\":{\"alloc\":[],\"async\":[\"futures\",\"std\"],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", - "which_6.0.3": "{\"dependencies\":[{\"name\":\"either\",\"req\":\"^1.9.0\"},{\"name\":\"home\",\"req\":\"^0.5.9\",\"target\":\"cfg(any(windows, unix, target_os = \\\"redox\\\"))\"},{\"name\":\"regex\",\"optional\":true,\"req\":\"^1.10.2\"},{\"default_features\":false,\"features\":[\"fs\",\"std\"],\"name\":\"rustix\",\"req\":\"^0.38.30\",\"target\":\"cfg(any(unix, target_os = \\\"wasi\\\", target_os = \\\"redox\\\"))\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.9.0\"},{\"default_features\":false,\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.40\"},{\"features\":[\"kernel\"],\"name\":\"winsafe\",\"req\":\"^0.0.19\",\"target\":\"cfg(windows)\"}],\"features\":{\"regex\":[\"dep:regex\"],\"tracing\":[\"dep:tracing\"]}}", + "which_8.0.0": "{\"dependencies\":[{\"name\":\"env_home\",\"optional\":true,\"req\":\"^0.1.0\",\"target\":\"cfg(any(windows, unix, target_os = \\\"redox\\\"))\"},{\"name\":\"regex\",\"optional\":true,\"req\":\"^1.10.2\"},{\"default_features\":false,\"features\":[\"fs\",\"std\"],\"name\":\"rustix\",\"optional\":true,\"req\":\"^1.0.5\",\"target\":\"cfg(any(unix, target_os = \\\"wasi\\\", target_os = \\\"redox\\\"))\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.9.0\"},{\"default_features\":false,\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.40\"},{\"features\":[\"kernel\"],\"name\":\"winsafe\",\"optional\":true,\"req\":\"^0.0.19\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"real-sys\"],\"real-sys\":[\"dep:env_home\",\"dep:rustix\",\"dep:winsafe\"],\"regex\":[\"dep:regex\"],\"tracing\":[\"dep:tracing\"]}}", "wildmatch_2.6.1": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"glob\",\"req\":\"^0.3.1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"ntest\",\"req\":\"^0.9.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.5\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.10.2\"},{\"kind\":\"dev\",\"name\":\"regex-lite\",\"req\":\"^0.1.5\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"serde\":[\"dep:serde\"]}}", "winapi-i686-pc-windows-gnu_0.4.0": "{\"dependencies\":[],\"features\":{}}", "winapi-util_0.1.9": "{\"dependencies\":[{\"features\":[\"Win32_Foundation\",\"Win32_Storage_FileSystem\",\"Win32_System_Console\",\"Win32_System_SystemInformation\"],\"name\":\"windows-sys\",\"req\":\">=0.48.0, <=0.59\",\"target\":\"cfg(windows)\"}],\"features\":{}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index d355da65bd..9b9683eb38 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -984,8 +984,10 @@ dependencies = [ "thiserror 2.0.17", "tokio", "tokio-test", + "tokio-tungstenite", "tokio-util", "tracing", + "url", "wiremock", ] @@ -1273,6 +1275,7 @@ dependencies = [ "base64", "chardetng", "chrono", + "clap", "codex-api", "codex-app-server-protocol", "codex-apply-patch", @@ -1306,6 +1309,7 @@ dependencies = [ "image", "include_dir", "indexmap 2.12.0", + "indoc", "keyring", "landlock", "libc", @@ -1320,6 +1324,7 @@ dependencies = [ "regex", "regex-lite", "reqwest", + "schemars 0.8.22", "seccompiler", "serde", "serde_json", @@ -1597,7 +1602,9 @@ dependencies = [ "bytes", "codex-core", "futures", + "pretty_assertions", "reqwest", + "semver", "serde_json", "tokio", "tracing", @@ -1700,6 +1707,7 @@ dependencies = [ "pretty_assertions", "reqwest", "rmcp", + "schemars 0.8.22", "serde", "serde_json", "serial_test", @@ -2127,6 +2135,7 @@ dependencies = [ "codex-protocol", "codex-utils-absolute-path", "codex-utils-cargo-bin", + "futures", "notify", "pretty_assertions", "regex-lite", @@ -2135,6 +2144,7 @@ dependencies = [ "shlex", "tempfile", "tokio", + "tokio-tungstenite", "walkdir", "wiremock", ] @@ -2362,6 +2372,12 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + [[package]] name = "dbus" version = "0.9.9" @@ -7118,6 +7134,18 @@ dependencies = [ "tokio-stream", ] +[[package]] +name = "tokio-tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -7512,6 +7540,25 @@ dependencies = [ "ratatui-core", ] +[[package]] +name = "tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http 1.3.1", + "httparse", + "log", + "rand 0.8.5", + "sha1", + "thiserror 1.0.69", + "url", + "utf-8", +] + [[package]] name = "typenum" version = "1.18.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 3befc31744..74d026eb97 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -142,6 +142,7 @@ icu_decimal = "2.1" icu_locale_core = "2.1" icu_provider = { version = "2.1", features = ["sync"] } ignore = "0.4.23" +indoc = "2.0" image = { version = "^0.25.9", default-features = false } include_dir = "0.7.4" indexmap = "2.12.0" @@ -193,6 +194,7 @@ serde_yaml = "0.9" serial_test = "3.2.0" sha1 = "0.10.6" sha2 = "0.10" +semver = "1.0" shlex = "1.3.0" similar = "2.7.0" socket2 = "0.6.1" @@ -210,6 +212,7 @@ tiny_http = "0.12" tokio = "1" tokio-stream = "0.1.18" tokio-test = "0.4" +tokio-tungstenite = "0.21.0" tokio-util = "0.7.18" toml = "0.9.5" toml_edit = "0.24.0" diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 16ee3b9824..59d073ea80 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -156,6 +156,11 @@ client_request_definitions! { response: v2::McpServerOauthLoginResponse, }, + McpServerRefresh => "config/mcpServer/reload" { + params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, + response: v2::McpServerRefreshResponse, + }, + McpServerStatusList => "mcpServerStatus/list" { params: v2::ListMcpServerStatusParams, response: v2::ListMcpServerStatusResponse, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 348df069fc..30505cf067 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -940,6 +940,16 @@ pub struct ListMcpServerStatusResponse { pub next_cursor: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerRefreshParams {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerRefreshResponse {} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 597f002c0d..26c2dee9cf 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -88,6 +88,7 @@ Example (from OpenAI's official VSCode extension): - `model/list` — list available models (with reasoning effort options). - `skills/list` — list skills for one or more `cwd` values (optional `forceReload`). - `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. +- `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. - `mcpServerStatus/list` — enumerate configured MCP servers with their tools, resources, resource templates, and auth status; supports cursor+limit pagination. - `feedback/upload` — submit a feedback report (classification + optional reason/logs and conversation_id); returns the tracking thread id. - `command/exec` — run a single command under the server sandbox without starting a thread/turn (handy for utilities and validation). diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 391ee40cdf..e1428d1344 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -60,6 +60,7 @@ use codex_app_server_protocol::LogoutChatGptResponse; use codex_app_server_protocol::McpServerOauthLoginCompletedNotification; use codex_app_server_protocol::McpServerOauthLoginParams; use codex_app_server_protocol::McpServerOauthLoginResponse; +use codex_app_server_protocol::McpServerRefreshResponse; use codex_app_server_protocol::McpServerStatus; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; @@ -157,6 +158,7 @@ use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::GitInfo as CoreGitInfo; use codex_protocol::protocol::McpAuthStatus as CoreMcpAuthStatus; +use codex_protocol::protocol::McpServerRefreshConfig; use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionMetaLine; @@ -425,6 +427,9 @@ impl CodexMessageProcessor { ClientRequest::McpServerOauthLogin { request_id, params } => { self.mcp_server_oauth_login(request_id, params).await; } + ClientRequest::McpServerRefresh { request_id, params } => { + self.mcp_server_refresh(request_id, params).await; + } ClientRequest::McpServerStatusList { request_id, params } => { self.list_mcp_server_status(request_id, params).await; } @@ -2302,6 +2307,57 @@ impl CodexMessageProcessor { outgoing.send_response(request_id, response).await; } + async fn mcp_server_refresh(&self, request_id: RequestId, _params: Option<()>) { + let config = match self.load_latest_config().await { + Ok(config) => config, + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + let mcp_servers = match serde_json::to_value(config.mcp_servers.get()) { + Ok(value) => value, + Err(err) => { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to serialize MCP servers: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + let mcp_oauth_credentials_store_mode = + match serde_json::to_value(config.mcp_oauth_credentials_store_mode) { + Ok(value) => value, + Err(err) => { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!( + "failed to serialize MCP OAuth credentials store mode: {err}" + ), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + let refresh_config = McpServerRefreshConfig { + mcp_servers, + mcp_oauth_credentials_store_mode, + }; + + // Refresh requests are queued per thread; each thread rebuilds MCP connections on its next + // active turn to avoid work for threads that never resume. + let thread_manager = Arc::clone(&self.thread_manager); + thread_manager.refresh_mcp_servers(refresh_config).await; + let response = McpServerRefreshResponse {}; + self.outgoing.send_response(request_id, response).await; + } + async fn mcp_server_oauth_login( &self, request_id: RequestId, @@ -2321,7 +2377,7 @@ impl CodexMessageProcessor { timeout_secs, } = params; - let Some(server) = config.mcp_servers.get(&name) else { + let Some(server) = config.mcp_servers.get().get(&name) else { let error = JSONRPCErrorError { code: INVALID_REQUEST_ERROR_CODE, message: format!("No MCP server named '{name}' found."), diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index 25434ce92b..dd525cb615 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -135,6 +135,7 @@ mod tests { CoreSandboxModeRequirement::ReadOnly, CoreSandboxModeRequirement::ExternalSandbox, ]), + mcp_server_requirements: None, }; let mapped = map_requirements_toml_to_api(requirements); diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 68739c0080..d9aaabd1ca 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -44,6 +44,7 @@ pub async fn run_main( codex_linux_sandbox_exe: Option, cli_config_overrides: CliConfigOverrides, loader_overrides: LoaderOverrides, + default_analytics_enabled: bool, ) -> IoResult<()> { // Set up channels. let (incoming_tx, mut incoming_rx) = mpsc::channel::(CHANNEL_CAPACITY); @@ -96,7 +97,7 @@ pub async fn run_main( &config, env!("CARGO_PKG_VERSION"), Some("codex_app_server"), - false, + default_analytics_enabled, ) .map_err(|e| { std::io::Error::new( diff --git a/codex-rs/app-server/src/main.rs b/codex-rs/app-server/src/main.rs index be57311e83..71d6dc338c 100644 --- a/codex-rs/app-server/src/main.rs +++ b/codex-rs/app-server/src/main.rs @@ -20,6 +20,7 @@ fn main() -> anyhow::Result<()> { codex_linux_sandbox_exe, CliConfigOverrides::default(), loader_overrides, + false, ) .await?; Ok(()) diff --git a/codex-rs/app-server/tests/suite/send_message.rs b/codex-rs/app-server/tests/suite/send_message.rs index f57b5f2ee4..83e809f48e 100644 --- a/codex-rs/app-server/tests/suite/send_message.rs +++ b/codex-rs/app-server/tests/suite/send_message.rs @@ -13,11 +13,15 @@ use codex_app_server_protocol::SendUserMessageParams; use codex_app_server_protocol::SendUserMessageResponse; use codex_protocol::ThreadId; use codex_protocol::models::ContentItem; +use codex_protocol::models::DeveloperInstructions; use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::RawResponseItemEvent; +use codex_protocol::protocol::SandboxPolicy; use core_test_support::responses; use pretty_assertions::assert_eq; use std::path::Path; +use std::path::PathBuf; use tempfile::TempDir; use tokio::time::timeout; @@ -194,6 +198,9 @@ async fn test_send_message_raw_notifications_opt_in() -> Result<()> { }) .await?; + let permissions = read_raw_response_item(&mut mcp, conversation_id).await; + assert_permissions_message(&permissions); + let developer = read_raw_response_item(&mut mcp, conversation_id).await; assert_developer_message(&developer, "Use the test harness tools."); @@ -340,6 +347,27 @@ fn assert_instructions_message(item: &ResponseItem) { } } +fn assert_permissions_message(item: &ResponseItem) { + match item { + ResponseItem::Message { role, content, .. } => { + assert_eq!(role, "developer"); + let texts = content_texts(content); + let expected = DeveloperInstructions::from_policy( + &SandboxPolicy::DangerFullAccess, + AskForApproval::Never, + &PathBuf::from("/tmp"), + ) + .into_text(); + assert_eq!( + texts, + vec![expected.as_str()], + "expected permissions developer message, got {texts:?}" + ); + } + other => panic!("expected permissions message, got {other:?}"), + } +} + fn assert_developer_message(item: &ResponseItem, expected_text: &str) { match item { ResponseItem::Message { role, content, .. } => { diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs new file mode 100644 index 0000000000..e18a0d3c84 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -0,0 +1,66 @@ +use anyhow::Result; +use codex_core::config::ConfigBuilder; +use codex_core::config::types::OtelExporterKind; +use codex_core::config::types::OtelHttpProtocol; +use pretty_assertions::assert_eq; +use std::collections::HashMap; +use tempfile::TempDir; + +const SERVICE_VERSION: &str = "0.0.0-test"; + +fn set_metrics_exporter(config: &mut codex_core::config::Config) { + config.otel.metrics_exporter = OtelExporterKind::OtlpHttp { + endpoint: "http://localhost:4318".to_string(), + headers: HashMap::new(), + protocol: OtelHttpProtocol::Json, + tls: None, + }; +} + +#[tokio::test] +async fn app_server_default_analytics_disabled_without_flag() -> Result<()> { + let codex_home = TempDir::new()?; + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await?; + set_metrics_exporter(&mut config); + config.analytics_enabled = None; + + let provider = codex_core::otel_init::build_provider( + &config, + SERVICE_VERSION, + Some("codex_app_server"), + false, + ) + .map_err(|err| anyhow::anyhow!(err.to_string()))?; + + // With analytics unset in the config and the default flag is false, metrics are disabled. + // No provider is built. + assert_eq!(provider.is_none(), true); + Ok(()) +} + +#[tokio::test] +async fn app_server_default_analytics_enabled_with_flag() -> Result<()> { + let codex_home = TempDir::new()?; + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await?; + set_metrics_exporter(&mut config); + config.analytics_enabled = None; + + let provider = codex_core::otel_init::build_provider( + &config, + SERVICE_VERSION, + Some("codex_app_server"), + true, + ) + .map_err(|err| anyhow::anyhow!(err.to_string()))?; + + // With analytics unset in the config and the default flag is true, metrics are enabled. + let has_metrics = provider.as_ref().and_then(|otel| otel.metrics()).is_some(); + assert_eq!(has_metrics, true); + Ok(()) +} diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 5c40c5fc16..b2159ab9ce 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -1,4 +1,5 @@ mod account; +mod analytics; mod config_rpc; mod initialize; mod model_list; diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 918626c718..c29da72712 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -268,6 +268,24 @@ struct AppServerCommand { /// Omit to run the app server; specify a subcommand for tooling. #[command(subcommand)] subcommand: Option, + + /// Controls whether analytics are enabled by default. + /// + /// Analytics are disabled by default for app-server. Users have to explicitly opt in + /// via the `analytics` section in the config.toml file. + /// + /// However, for first-party use cases like the VSCode IDE extension, we default analytics + /// to be enabled by default by setting this flag. Users can still opt out by setting this + /// in their config.toml: + /// + /// ```toml + /// [analytics] + /// enabled = false + /// ``` + /// + /// See https://developers.openai.com/codex/config-advanced/#metrics for more details. + #[arg(long = "analytics-default-enabled")] + analytics_default_enabled: bool, } #[derive(Debug, clap::Subcommand)] @@ -500,6 +518,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() codex_linux_sandbox_exe, root_config_overrides, codex_core::config_loader::LoaderOverrides::default(), + app_server_cli.analytics_default_enabled, ) .await?; } @@ -910,6 +929,14 @@ mod tests { finalize_fork_interactive(interactive, root_overrides, session_id, last, all, fork_cli) } + fn app_server_from_args(args: &[&str]) -> AppServerCommand { + let cli = MultitoolCli::try_parse_from(args).expect("parse"); + let Subcommand::AppServer(app_server) = cli.subcommand.expect("app-server present") else { + unreachable!() + }; + app_server + } + fn sample_exit_info(conversation: Option<&str>) -> AppExitInfo { let token_usage = TokenUsage { output_tokens: 2, @@ -1108,6 +1135,19 @@ mod tests { assert!(interactive.fork_show_all); } + #[test] + fn app_server_analytics_default_disabled_without_flag() { + let app_server = app_server_from_args(["codex", "app-server"].as_ref()); + assert!(!app_server.analytics_default_enabled); + } + + #[test] + fn app_server_analytics_default_enabled_with_flag() { + let app_server = + app_server_from_args(["codex", "app-server", "--analytics-default-enabled"].as_ref()); + assert!(app_server.analytics_default_enabled); + } + #[test] fn feature_toggles_known_features_generate_overrides() { let toggles = FeatureToggles { diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index 497ac83971..30c6fa21f8 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -332,7 +332,7 @@ async fn run_login(config_overrides: &CliConfigOverrides, login_args: LoginArgs) let LoginArgs { name, scopes } = login_args; - let Some(server) = config.mcp_servers.get(&name) else { + let Some(server) = config.mcp_servers.get().get(&name) else { bail!("No MCP server named '{name}' found."); }; @@ -372,6 +372,7 @@ async fn run_logout(config_overrides: &CliConfigOverrides, logout_args: LogoutAr let server = config .mcp_servers + .get() .get(&name) .ok_or_else(|| anyhow!("No MCP server named '{name}' found in configuration."))?; @@ -654,7 +655,7 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re .await .context("failed to load configuration")?; - let Some(server) = config.mcp_servers.get(&get_args.name) else { + let Some(server) = config.mcp_servers.get().get(&get_args.name) else { bail!("No MCP server named '{name}' found.", name = get_args.name); }; diff --git a/codex-rs/codex-api/Cargo.toml b/codex-rs/codex-api/Cargo.toml index e9fc78878b..761e572369 100644 --- a/codex-rs/codex-api/Cargo.toml +++ b/codex-rs/codex-api/Cargo.toml @@ -14,11 +14,13 @@ http = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } thiserror = { workspace = true } -tokio = { workspace = true, features = ["macros", "rt", "sync", "time"] } +tokio = { workspace = true, features = ["macros", "net", "rt", "sync", "time"] } +tokio-tungstenite = { workspace = true } tracing = { workspace = true } eventsource-stream = { workspace = true } regex-lite = { workspace = true } tokio-util = { workspace = true, features = ["codec"] } +url = { workspace = true } [dev-dependencies] anyhow = { workspace = true } diff --git a/codex-rs/codex-api/src/common.rs b/codex-rs/codex-api/src/common.rs index db1524d270..2118cf66e4 100644 --- a/codex-rs/codex-api/src/common.rs +++ b/codex-rs/codex-api/src/common.rs @@ -136,6 +136,38 @@ pub struct ResponsesApiRequest<'a> { pub text: Option, } +#[derive(Debug, Serialize)] +pub struct ResponseCreateWsRequest { + pub model: String, + pub instructions: String, + pub input: Vec, + pub tools: Vec, + pub tool_choice: String, + pub parallel_tool_calls: bool, + pub reasoning: Option, + pub store: bool, + pub stream: bool, + pub include: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt_cache_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub text: Option, +} + +#[derive(Debug, Serialize)] +pub struct ResponseAppendWsRequest { + pub input: Vec, +} +#[derive(Debug, Serialize)] +#[serde(tag = "type")] +#[allow(clippy::large_enum_variant)] +pub enum ResponsesWsRequest { + #[serde(rename = "response.create")] + ResponseCreate(ResponseCreateWsRequest), + #[serde(rename = "response.append")] + ResponseAppend(ResponseAppendWsRequest), +} + pub fn create_text_param_for_request( verbosity: Option, output_schema: &Option, diff --git a/codex-rs/codex-api/src/endpoint/mod.rs b/codex-rs/codex-api/src/endpoint/mod.rs index cb0eeb9f20..2fa116c089 100644 --- a/codex-rs/codex-api/src/endpoint/mod.rs +++ b/codex-rs/codex-api/src/endpoint/mod.rs @@ -2,4 +2,5 @@ pub mod chat; pub mod compact; pub mod models; pub mod responses; +pub mod responses_websocket; mod streaming; diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs new file mode 100644 index 0000000000..af3335ce76 --- /dev/null +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -0,0 +1,253 @@ +use crate::auth::AuthProvider; +use crate::common::ResponseEvent; +use crate::common::ResponseStream; +use crate::common::ResponsesWsRequest; +use crate::error::ApiError; +use crate::provider::Provider; +use crate::sse::responses::ResponsesStreamEvent; +use crate::sse::responses::process_responses_event; +use codex_client::TransportError; +use futures::SinkExt; +use futures::StreamExt; +use http::HeaderMap; +use http::HeaderValue; +use serde_json::Value; +use std::sync::Arc; +use std::time::Duration; +use tokio::net::TcpStream; +use tokio::sync::Mutex; +use tokio::sync::mpsc; +use tokio_tungstenite::MaybeTlsStream; +use tokio_tungstenite::WebSocketStream; +use tokio_tungstenite::tungstenite::Error as WsError; +use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tracing::debug; +use tracing::trace; +use url::Url; + +type WsStream = WebSocketStream>; + +pub struct ResponsesWebsocketConnection { + stream: Arc>>, + // TODO (pakrym): is this the right place for timeout? + idle_timeout: Duration, +} + +impl ResponsesWebsocketConnection { + fn new(stream: WsStream, idle_timeout: Duration) -> Self { + Self { + stream: Arc::new(Mutex::new(Some(stream))), + idle_timeout, + } + } + + pub async fn is_closed(&self) -> bool { + self.stream.lock().await.is_none() + } + + pub async fn stream_request( + &self, + request: ResponsesWsRequest, + ) -> Result { + let (tx_event, rx_event) = + mpsc::channel::>(1600); + let stream = Arc::clone(&self.stream); + let idle_timeout = self.idle_timeout; + let request_body = serde_json::to_value(&request).map_err(|err| { + ApiError::Stream(format!("failed to encode websocket request: {err}")) + })?; + + tokio::spawn(async move { + let mut guard = stream.lock().await; + let Some(ws_stream) = guard.as_mut() else { + let _ = tx_event + .send(Err(ApiError::Stream( + "websocket connection is closed".to_string(), + ))) + .await; + return; + }; + + if let Err(err) = run_websocket_response_stream( + ws_stream, + tx_event.clone(), + request_body, + idle_timeout, + ) + .await + { + let _ = ws_stream.close(None).await; + *guard = None; + let _ = tx_event.send(Err(err)).await; + } + }); + + Ok(ResponseStream { rx_event }) + } +} + +pub struct ResponsesWebsocketClient { + provider: Provider, + auth: A, +} + +impl ResponsesWebsocketClient { + pub fn new(provider: Provider, auth: A) -> Self { + Self { provider, auth } + } + + pub async fn connect( + &self, + extra_headers: HeaderMap, + ) -> Result { + let ws_url = Url::parse(&self.provider.url_for_path("responses")) + .map_err(|err| ApiError::Stream(format!("failed to build websocket URL: {err}")))?; + + let mut headers = self.provider.headers.clone(); + headers.extend(extra_headers); + apply_auth_headers(&mut headers, &self.auth); + + let stream = connect_websocket(ws_url, headers).await?; + Ok(ResponsesWebsocketConnection::new( + stream, + self.provider.stream_idle_timeout, + )) + } +} + +// TODO (pakrym): share with /auth +fn apply_auth_headers(headers: &mut HeaderMap, auth: &impl AuthProvider) { + if let Some(token) = auth.bearer_token() + && let Ok(header) = HeaderValue::from_str(&format!("Bearer {token}")) + { + let _ = headers.insert(http::header::AUTHORIZATION, header); + } + if let Some(account_id) = auth.account_id() + && let Ok(header) = HeaderValue::from_str(&account_id) + { + let _ = headers.insert("ChatGPT-Account-ID", header); + } +} + +async fn connect_websocket(url: Url, headers: HeaderMap) -> Result { + let mut request = url + .clone() + .into_client_request() + .map_err(|err| ApiError::Stream(format!("failed to build websocket request: {err}")))?; + request.headers_mut().extend(headers); + + let (stream, _) = tokio_tungstenite::connect_async(request) + .await + .map_err(|err| map_ws_error(err, &url))?; + Ok(stream) +} + +fn map_ws_error(err: WsError, url: &Url) -> ApiError { + match err { + WsError::Http(response) => { + let status = response.status(); + let headers = response.headers().clone(); + let body = response + .body() + .as_ref() + .and_then(|bytes| String::from_utf8(bytes.clone()).ok()); + ApiError::Transport(TransportError::Http { + status, + url: Some(url.to_string()), + headers: Some(headers), + body, + }) + } + WsError::ConnectionClosed | WsError::AlreadyClosed => { + ApiError::Stream("websocket closed".to_string()) + } + WsError::Io(err) => ApiError::Transport(TransportError::Network(err.to_string())), + other => ApiError::Transport(TransportError::Network(other.to_string())), + } +} + +async fn run_websocket_response_stream( + ws_stream: &mut WsStream, + tx_event: mpsc::Sender>, + request_body: Value, + idle_timeout: Duration, +) -> Result<(), ApiError> { + let request_text = match serde_json::to_string(&request_body) { + Ok(text) => text, + Err(err) => { + return Err(ApiError::Stream(format!( + "failed to encode websocket request: {err}" + ))); + } + }; + + if let Err(err) = ws_stream.send(Message::Text(request_text)).await { + return Err(ApiError::Stream(format!( + "failed to send websocket request: {err}" + ))); + } + + loop { + let response = tokio::time::timeout(idle_timeout, ws_stream.next()) + .await + .map_err(|_| ApiError::Stream("idle timeout waiting for websocket".into())); + let message = match response { + Ok(Some(Ok(msg))) => msg, + Ok(Some(Err(err))) => { + return Err(ApiError::Stream(err.to_string())); + } + Ok(None) => { + return Err(ApiError::Stream( + "stream closed before response.completed".into(), + )); + } + Err(err) => { + return Err(err); + } + }; + + match message { + Message::Text(text) => { + trace!("websocket event: {text}"); + let event = match serde_json::from_str::(&text) { + Ok(event) => event, + Err(err) => { + debug!("failed to parse websocket event: {err}, data: {text}"); + continue; + } + }; + match process_responses_event(event) { + Ok(Some(event)) => { + let is_completed = matches!(event, ResponseEvent::Completed { .. }); + let _ = tx_event.send(Ok(event)).await; + if is_completed { + break; + } + } + Ok(None) => {} + Err(error) => { + return Err(error.into_api_error()); + } + } + } + Message::Binary(_) => { + return Err(ApiError::Stream("unexpected binary websocket event".into())); + } + Message::Ping(payload) => { + if ws_stream.send(Message::Pong(payload)).await.is_err() { + return Err(ApiError::Stream("websocket ping failed".into())); + } + } + Message::Pong(_) => {} + Message::Close(_) => { + return Err(ApiError::Stream( + "websocket closed before response.completed".into(), + )); + } + _ => {} + } + } + + Ok(()) +} diff --git a/codex-rs/codex-api/src/lib.rs b/codex-rs/codex-api/src/lib.rs index d0c382ac8c..0f608fd239 100644 --- a/codex-rs/codex-api/src/lib.rs +++ b/codex-rs/codex-api/src/lib.rs @@ -8,6 +8,7 @@ pub mod requests; pub mod sse; pub mod telemetry; +pub use crate::requests::headers::build_conversation_headers; pub use codex_client::RequestTelemetry; pub use codex_client::ReqwestTransport; pub use codex_client::TransportError; @@ -15,6 +16,8 @@ pub use codex_client::TransportError; pub use crate::auth::AuthProvider; pub use crate::common::CompactionInput; pub use crate::common::Prompt; +pub use crate::common::ResponseAppendWsRequest; +pub use crate::common::ResponseCreateWsRequest; pub use crate::common::ResponseEvent; pub use crate::common::ResponseStream; pub use crate::common::ResponsesApiRequest; @@ -25,6 +28,8 @@ pub use crate::endpoint::compact::CompactClient; pub use crate::endpoint::models::ModelsClient; pub use crate::endpoint::responses::ResponsesClient; pub use crate::endpoint::responses::ResponsesOptions; +pub use crate::endpoint::responses_websocket::ResponsesWebsocketClient; +pub use crate::endpoint::responses_websocket::ResponsesWebsocketConnection; pub use crate::error::ApiError; pub use crate::provider::Provider; pub use crate::provider::WireApi; diff --git a/codex-rs/codex-api/src/requests/chat.rs b/codex-rs/codex-api/src/requests/chat.rs index 60f450ca0d..30d8b478d2 100644 --- a/codex-rs/codex-api/src/requests/chat.rs +++ b/codex-rs/codex-api/src/requests/chat.rs @@ -393,10 +393,6 @@ mod tests { .build(&provider()) .expect("request"); - assert_eq!( - req.headers.get("conversation_id"), - Some(&HeaderValue::from_static("conv-1")) - ); assert_eq!( req.headers.get("session_id"), Some(&HeaderValue::from_static("conv-1")) diff --git a/codex-rs/codex-api/src/requests/headers.rs b/codex-rs/codex-api/src/requests/headers.rs index 4d8a17d18d..02f08724ff 100644 --- a/codex-rs/codex-api/src/requests/headers.rs +++ b/codex-rs/codex-api/src/requests/headers.rs @@ -2,10 +2,9 @@ use codex_protocol::protocol::SessionSource; use http::HeaderMap; use http::HeaderValue; -pub(crate) fn build_conversation_headers(conversation_id: Option) -> HeaderMap { +pub fn build_conversation_headers(conversation_id: Option) -> HeaderMap { let mut headers = HeaderMap::new(); if let Some(id) = conversation_id { - insert_header(&mut headers, "conversation_id", &id); insert_header(&mut headers, "session_id", &id); } headers diff --git a/codex-rs/codex-api/src/requests/responses.rs b/codex-rs/codex-api/src/requests/responses.rs index a18a147aba..65f7dc0243 100644 --- a/codex-rs/codex-api/src/requests/responses.rs +++ b/codex-rs/codex-api/src/requests/responses.rs @@ -249,10 +249,6 @@ mod tests { .collect(); assert_eq!(ids, vec![Some("m1".to_string()), None]); - assert_eq!( - request.headers.get("conversation_id"), - Some(&HeaderValue::from_static("conv-1")) - ); assert_eq!( request.headers.get("session_id"), Some(&HeaderValue::from_static("conv-1")) diff --git a/codex-rs/codex-api/src/sse/responses.rs b/codex-rs/codex-api/src/sse/responses.rs index 2ec8271c29..7f0981c5c8 100644 --- a/codex-rs/codex-api/src/sse/responses.rs +++ b/codex-rs/codex-api/src/sse/responses.rs @@ -88,6 +88,14 @@ struct ResponseCompleted { usage: Option, } +#[derive(Debug, Deserialize)] +struct ResponseDone { + #[serde(default)] + id: Option, + #[serde(default)] + usage: Option, +} + #[derive(Debug, Deserialize)] struct ResponseCompletedUsage { input_tokens: i64, @@ -126,7 +134,7 @@ struct ResponseCompletedOutputTokensDetails { } #[derive(Deserialize, Debug)] -struct SseEvent { +pub struct ResponsesStreamEvent { #[serde(rename = "type")] kind: String, response: Option, @@ -136,6 +144,145 @@ struct SseEvent { content_index: Option, } +#[derive(Debug)] +pub enum ResponsesEventError { + Api(ApiError), +} + +impl ResponsesEventError { + pub fn into_api_error(self) -> ApiError { + match self { + Self::Api(error) => error, + } + } +} + +pub fn process_responses_event( + event: ResponsesStreamEvent, +) -> std::result::Result, ResponsesEventError> { + match event.kind.as_str() { + "response.output_item.done" => { + if let Some(item_val) = event.item { + if let Ok(item) = serde_json::from_value::(item_val) { + return Ok(Some(ResponseEvent::OutputItemDone(item))); + } + debug!("failed to parse ResponseItem from output_item.done"); + } + } + "response.output_text.delta" => { + if let Some(delta) = event.delta { + return Ok(Some(ResponseEvent::OutputTextDelta(delta))); + } + } + "response.reasoning_summary_text.delta" => { + if let (Some(delta), Some(summary_index)) = (event.delta, event.summary_index) { + return Ok(Some(ResponseEvent::ReasoningSummaryDelta { + delta, + summary_index, + })); + } + } + "response.reasoning_text.delta" => { + if let (Some(delta), Some(content_index)) = (event.delta, event.content_index) { + return Ok(Some(ResponseEvent::ReasoningContentDelta { + delta, + content_index, + })); + } + } + "response.created" => { + if event.response.is_some() { + return Ok(Some(ResponseEvent::Created {})); + } + } + "response.failed" => { + if let Some(resp_val) = event.response { + let mut response_error = ApiError::Stream("response.failed event received".into()); + if let Some(error) = resp_val.get("error") + && let Ok(error) = serde_json::from_value::(error.clone()) + { + if is_context_window_error(&error) { + response_error = ApiError::ContextWindowExceeded; + } else if is_quota_exceeded_error(&error) { + response_error = ApiError::QuotaExceeded; + } else if is_usage_not_included(&error) { + response_error = ApiError::UsageNotIncluded; + } else { + let delay = try_parse_retry_after(&error); + let message = error.message.unwrap_or_default(); + response_error = ApiError::Retryable { message, delay }; + } + } + return Err(ResponsesEventError::Api(response_error)); + } + + return Err(ResponsesEventError::Api(ApiError::Stream( + "response.failed event received".into(), + ))); + } + "response.completed" => { + if let Some(resp_val) = event.response { + match serde_json::from_value::(resp_val) { + Ok(resp) => { + return Ok(Some(ResponseEvent::Completed { + response_id: resp.id, + token_usage: resp.usage.map(Into::into), + })); + } + Err(err) => { + let error = format!("failed to parse ResponseCompleted: {err}"); + debug!("{error}"); + return Err(ResponsesEventError::Api(ApiError::Stream(error))); + } + } + } + } + "response.done" => { + if let Some(resp_val) = event.response { + match serde_json::from_value::(resp_val) { + Ok(resp) => { + return Ok(Some(ResponseEvent::Completed { + response_id: resp.id.unwrap_or_default(), + token_usage: resp.usage.map(Into::into), + })); + } + Err(err) => { + let error = format!("failed to parse ResponseCompleted: {err}"); + debug!("{error}"); + return Err(ResponsesEventError::Api(ApiError::Stream(error))); + } + } + } + + debug!("response.done missing response payload"); + return Ok(Some(ResponseEvent::Completed { + response_id: String::new(), + token_usage: None, + })); + } + "response.output_item.added" => { + if let Some(item_val) = event.item { + if let Ok(item) = serde_json::from_value::(item_val) { + return Ok(Some(ResponseEvent::OutputItemAdded(item))); + } + debug!("failed to parse ResponseItem from output_item.done"); + } + } + "response.reasoning_summary_part.added" => { + if let Some(summary_index) = event.summary_index { + return Ok(Some(ResponseEvent::ReasoningSummaryPartAdded { + summary_index, + })); + } + } + _ => { + trace!("unhandled responses event: {}", event.kind); + } + } + + Ok(None) +} + pub async fn process_sse( stream: ByteStream, tx_event: mpsc::Sender>, @@ -143,7 +290,7 @@ pub async fn process_sse( telemetry: Option>, ) { let mut stream = stream.eventsource(); - let mut response_completed: Option = None; + let mut response_completed: Option = None; let mut response_error: Option = None; loop { @@ -161,11 +308,7 @@ pub async fn process_sse( } Ok(None) => { match response_completed.take() { - Some(ResponseCompleted { id, usage }) => { - let event = ResponseEvent::Completed { - response_id: id, - token_usage: usage.map(Into::into), - }; + Some(event) => { let _ = tx_event.send(Ok(event)).await; } None => { @@ -188,7 +331,7 @@ pub async fn process_sse( let raw = sse.data.clone(); trace!("SSE event: {raw}"); - let event: SseEvent = match serde_json::from_str(&sse.data) { + let event: ResponsesStreamEvent = match serde_json::from_str(&sse.data) { Ok(event) => event, Err(e) => { debug!("Failed to parse SSE event: {e}, data: {}", &sse.data); @@ -196,115 +339,19 @@ pub async fn process_sse( } }; - match event.kind.as_str() { - "response.output_item.done" => { - let Some(item_val) = event.item else { continue }; - let Ok(item) = serde_json::from_value::(item_val) else { - debug!("failed to parse ResponseItem from output_item.done"); - continue; - }; - - let event = ResponseEvent::OutputItemDone(item); - if tx_event.send(Ok(event)).await.is_err() { + match process_responses_event(event) { + Ok(Some(event)) => { + if matches!(event, ResponseEvent::Completed { .. }) { + response_completed = Some(event); + } else if tx_event.send(Ok(event)).await.is_err() { return; } } - "response.output_text.delta" => { - if let Some(delta) = event.delta { - let event = ResponseEvent::OutputTextDelta(delta); - if tx_event.send(Ok(event)).await.is_err() { - return; - } - } + Ok(None) => {} + Err(error) => { + response_error = Some(error.into_api_error()); } - "response.reasoning_summary_text.delta" => { - if let (Some(delta), Some(summary_index)) = (event.delta, event.summary_index) { - let event = ResponseEvent::ReasoningSummaryDelta { - delta, - summary_index, - }; - if tx_event.send(Ok(event)).await.is_err() { - return; - } - } - } - "response.reasoning_text.delta" => { - if let (Some(delta), Some(content_index)) = (event.delta, event.content_index) { - let event = ResponseEvent::ReasoningContentDelta { - delta, - content_index, - }; - if tx_event.send(Ok(event)).await.is_err() { - return; - } - } - } - "response.created" => { - if event.response.is_some() { - let _ = tx_event.send(Ok(ResponseEvent::Created {})).await; - } - } - "response.failed" => { - if let Some(resp_val) = event.response { - response_error = - Some(ApiError::Stream("response.failed event received".into())); - - if let Some(error) = resp_val.get("error") - && let Ok(error) = serde_json::from_value::(error.clone()) - { - if is_context_window_error(&error) { - response_error = Some(ApiError::ContextWindowExceeded); - } else if is_quota_exceeded_error(&error) { - response_error = Some(ApiError::QuotaExceeded); - } else if is_usage_not_included(&error) { - response_error = Some(ApiError::UsageNotIncluded); - } else { - let delay = try_parse_retry_after(&error); - let message = error.message.clone().unwrap_or_default(); - response_error = Some(ApiError::Retryable { message, delay }); - } - } - } - } - "response.completed" => { - if let Some(resp_val) = event.response { - match serde_json::from_value::(resp_val) { - Ok(r) => { - response_completed = Some(r); - } - Err(e) => { - let error = format!("failed to parse ResponseCompleted: {e}"); - debug!(error); - response_error = Some(ApiError::Stream(error)); - continue; - } - }; - }; - } - "response.output_item.added" => { - let Some(item_val) = event.item else { continue }; - let Ok(item) = serde_json::from_value::(item_val) else { - debug!("failed to parse ResponseItem from output_item.done"); - continue; - }; - - let event = ResponseEvent::OutputItemAdded(item); - if tx_event.send(Ok(event)).await.is_err() { - return; - } - } - "response.reasoning_summary_part.added" => { - if let Some(summary_index) = event.summary_index { - let event = ResponseEvent::ReasoningSummaryPartAdded { summary_index }; - if tx_event.send(Ok(event)).await.is_err() { - return; - } - } - } - _ => { - trace!("unhandled SSE event: {:#?}", event.kind); - } - } + }; } } @@ -501,6 +548,65 @@ mod tests { } } + #[tokio::test] + async fn response_done_emits_completed() { + let done = json!({ + "type": "response.done", + "response": { + "usage": { + "input_tokens": 1, + "input_tokens_details": null, + "output_tokens": 2, + "output_tokens_details": null, + "total_tokens": 3 + } + } + }) + .to_string(); + + let sse1 = format!("event: response.done\ndata: {done}\n\n"); + + let events = collect_events(&[sse1.as_bytes()]).await; + + assert_eq!(events.len(), 1); + + match &events[0] { + Ok(ResponseEvent::Completed { + response_id, + token_usage, + }) => { + assert_eq!(response_id, ""); + assert!(token_usage.is_some()); + } + other => panic!("unexpected event: {other:?}"), + } + } + + #[tokio::test] + async fn response_done_without_payload_emits_completed() { + let done = json!({ + "type": "response.done" + }) + .to_string(); + + let sse1 = format!("event: response.done\ndata: {done}\n\n"); + + let events = collect_events(&[sse1.as_bytes()]).await; + + assert_eq!(events.len(), 1); + + match &events[0] { + Ok(ResponseEvent::Completed { + response_id, + token_usage, + }) => { + assert_eq!(response_id, ""); + assert!(token_usage.is_none()); + } + other => panic!("unexpected event: {other:?}"), + } + } + #[tokio::test] async fn error_when_error_event() { let raw_error = r#"{"type":"response.failed","sequence_number":3,"response":{"id":"resp_689bcf18d7f08194bf3440ba62fe05d803fee0cdac429894","object":"response","created_at":1755041560,"status":"failed","background":false,"error":{"code":"rate_limit_exceeded","message":"Rate limit reached for gpt-5.1 in organization org-AAA on tokens per min (TPM): Limit 30000, Used 22999, Requested 12528. Please try again in 11.054s. Visit https://platform.openai.com/account/rate-limits to learn more."}, "usage":null,"user":null,"metadata":{}}}"#; diff --git a/codex-rs/common/src/oss.rs b/codex-rs/common/src/oss.rs index b2f511e478..f686bb6016 100644 --- a/codex-rs/common/src/oss.rs +++ b/codex-rs/common/src/oss.rs @@ -1,18 +1,52 @@ //! OSS provider utilities shared between TUI and exec. use codex_core::LMSTUDIO_OSS_PROVIDER_ID; +use codex_core::OLLAMA_CHAT_PROVIDER_ID; use codex_core::OLLAMA_OSS_PROVIDER_ID; +use codex_core::WireApi; use codex_core::config::Config; +use codex_core::protocol::DeprecationNoticeEvent; +use std::io; /// Returns the default model for a given OSS provider. pub fn get_default_model_for_oss_provider(provider_id: &str) -> Option<&'static str> { match provider_id { LMSTUDIO_OSS_PROVIDER_ID => Some(codex_lmstudio::DEFAULT_OSS_MODEL), - OLLAMA_OSS_PROVIDER_ID => Some(codex_ollama::DEFAULT_OSS_MODEL), + OLLAMA_OSS_PROVIDER_ID | OLLAMA_CHAT_PROVIDER_ID => Some(codex_ollama::DEFAULT_OSS_MODEL), _ => None, } } +/// Returns a deprecation notice if Ollama doesn't support the responses wire API. +pub async fn ollama_chat_deprecation_notice( + config: &Config, +) -> io::Result> { + if config.model_provider_id != OLLAMA_OSS_PROVIDER_ID + || config.model_provider.wire_api != WireApi::Responses + { + return Ok(None); + } + + if let Some(detection) = codex_ollama::detect_wire_api(&config.model_provider).await? + && detection.wire_api == WireApi::Chat + { + let version_suffix = detection + .version + .as_ref() + .map(|version| format!(" (version {version})")) + .unwrap_or_default(); + let summary = format!( + "Your Ollama server{version_suffix} doesn't support the Responses API. Either update Ollama or set `oss_provider = \"{OLLAMA_CHAT_PROVIDER_ID}\"` (or `model_provider = \"{OLLAMA_CHAT_PROVIDER_ID}\"`) in your config.toml to use the \"chat\" wire API. Support for the \"chat\" wire API is deprecated and will soon be removed." + ); + return Ok(Some(DeprecationNoticeEvent { + summary, + details: None, + })); + } + + Ok(None) +} + /// Ensures the specified OSS provider is ready (models downloaded, service reachable). pub async fn ensure_oss_provider_ready( provider_id: &str, @@ -24,7 +58,7 @@ pub async fn ensure_oss_provider_ready( .await .map_err(|e| std::io::Error::other(format!("OSS setup failed: {e}")))?; } - OLLAMA_OSS_PROVIDER_ID => { + OLLAMA_OSS_PROVIDER_ID | OLLAMA_CHAT_PROVIDER_ID => { codex_ollama::ensure_oss_ready(config) .await .map_err(|e| std::io::Error::other(format!("OSS setup failed: {e}")))?; diff --git a/codex-rs/core/BUILD.bazel b/codex-rs/core/BUILD.bazel index 67f87999b7..37a3173f9a 100644 --- a/codex-rs/core/BUILD.bazel +++ b/codex-rs/core/BUILD.bazel @@ -20,15 +20,18 @@ codex_rust_crate( "//codex-rs/apply-patch:apply_patch_tool_instructions.md", "prompt.md", ], - # This is a bit of a hack, but empirically, some of our integration tests - # are relying on the presence of this file as a repo root marker. When - # running tests locally, this "just works," but in remote execution, - # the working directory is different and so the file is not found unless it - # is explicitly added as test data. - # - # TODO(aibrahim): Update the tests so that `just bazel-remote-test` succeeds - # without this workaround. - test_data_extra = ["//:AGENTS.md"], + test_data_extra = [ + "config.schema.json", + # This is a bit of a hack, but empirically, some of our integration tests + # are relying on the presence of this file as a repo root marker. When + # running tests locally, this "just works," but in remote execution, + # the working directory is different and so the file is not found unless it + # is explicitly added as test data. + # + # TODO(aibrahim): Update the tests so that `just bazel-remote-test` + # succeeds without this workaround. + "//:AGENTS.md", + ], integration_deps_extra = ["//codex-rs/core/tests/common:common"], test_tags = ["no-sandbox"], extra_binaries = [ diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index a9e06bc6c3..d343ed6dea 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -9,17 +9,22 @@ doctest = false name = "codex_core" path = "src/lib.rs" +[[bin]] +name = "codex-write-config-schema" +path = "src/bin/config_schema.rs" + [lints] workspace = true [dependencies] anyhow = { workspace = true } +arc-swap = "1.7.1" async-channel = { workspace = true } async-trait = { workspace = true } -arc-swap = "1.7.1" base64 = { workspace = true } chardetng = { workspace = true } chrono = { workspace = true, features = ["serde"] } +clap = { workspace = true, features = ["derive"] } codex-api = { workspace = true } codex-app-server-protocol = { workspace = true } codex-apply-patch = { workspace = true } @@ -46,6 +51,7 @@ futures = { workspace = true } http = { workspace = true } include_dir = { workspace = true } indexmap = { workspace = true } +indoc = { workspace = true } keyring = { workspace = true, features = ["crypto-rust"] } libc = { workspace = true } mcp-types = { workspace = true } @@ -55,6 +61,7 @@ rand = { workspace = true } regex = { workspace = true } regex-lite = { workspace = true } reqwest = { workspace = true, features = ["json", "stream"] } +schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } serde_path_to_error = { workspace = true } @@ -123,8 +130,12 @@ keyring = { workspace = true, features = ["sync-secret-service"] } assert_cmd = { workspace = true } assert_matches = { workspace = true } codex-arg0 = { workspace = true } -codex-core = { path = ".", default-features = false, features = ["deterministic_process_ids"] } -codex-otel = { workspace = true, features = ["disable-default-metrics-exporter"] } +codex-core = { path = ".", default-features = false, features = [ + "deterministic_process_ids", +] } +codex-otel = { workspace = true, features = [ + "disable-default-metrics-exporter", +] } codex-utils-cargo-bin = { workspace = true } core_test_support = { workspace = true } ctor = { workspace = true } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json new file mode 100644 index 0000000000..773218af78 --- /dev/null +++ b/codex-rs/core/config.schema.json @@ -0,0 +1,1450 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ConfigToml", + "description": "Base config deserialized from ~/.codex/config.toml.", + "type": "object", + "properties": { + "analytics": { + "description": "When `false`, disables analytics across Codex product surfaces in this machine. Defaults to `true`.", + "allOf": [ + { + "$ref": "#/definitions/AnalyticsConfigToml" + } + ] + }, + "approval_policy": { + "description": "Default approval policy for executing commands.", + "allOf": [ + { + "$ref": "#/definitions/AskForApproval" + } + ] + }, + "chatgpt_base_url": { + "description": "Base URL for requests to ChatGPT (as opposed to the OpenAI API).", + "type": "string" + }, + "check_for_update_on_startup": { + "description": "When `true`, checks for Codex updates on startup and surfaces update prompts. Set to `false` only if your Codex updates are centrally managed. Defaults to `true`.", + "type": "boolean" + }, + "cli_auth_credentials_store": { + "description": "Preferred backend for storing CLI auth credentials. file (default): Use a file in the Codex home directory. keyring: Use an OS-specific keyring service. auto: Use the keyring if available, otherwise use a file.", + "default": null, + "allOf": [ + { + "$ref": "#/definitions/AuthCredentialsStoreMode" + } + ] + }, + "compact_prompt": { + "description": "Compact prompt used for history compaction.", + "type": "string" + }, + "developer_instructions": { + "description": "Developer instructions inserted as a `developer` role message.", + "default": null, + "type": "string" + }, + "disable_paste_burst": { + "description": "When true, disables burst-paste detection for typed input entirely. All characters are inserted as they are received, and no buffering or placeholder replacement will occur for fast keypress bursts.", + "type": "boolean" + }, + "experimental_compact_prompt_file": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "experimental_instructions_file": { + "description": "Legacy, now use features", + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ] + }, + "experimental_use_freeform_apply_patch": { + "type": "boolean" + }, + "experimental_use_unified_exec_tool": { + "type": "boolean" + }, + "features": { + "description": "Centralized feature flags (new). Prefer this over individual toggles.", + "default": null, + "type": "object", + "properties": { + "apply_patch_freeform": { + "type": "boolean" + }, + "collab": { + "type": "boolean" + }, + "elevated_windows_sandbox": { + "type": "boolean" + }, + "enable_experimental_windows_sandbox": { + "type": "boolean" + }, + "enable_request_compression": { + "type": "boolean" + }, + "exec_policy": { + "type": "boolean" + }, + "experimental_use_freeform_apply_patch": { + "type": "boolean" + }, + "experimental_use_unified_exec_tool": { + "type": "boolean" + }, + "experimental_windows_sandbox": { + "type": "boolean" + }, + "hierarchical_agents": { + "type": "boolean" + }, + "include_apply_patch_tool": { + "type": "boolean" + }, + "powershell_utf8": { + "type": "boolean" + }, + "remote_compaction": { + "type": "boolean" + }, + "remote_models": { + "type": "boolean" + }, + "shell_snapshot": { + "type": "boolean" + }, + "shell_tool": { + "type": "boolean" + }, + "steer": { + "type": "boolean" + }, + "tui2": { + "type": "boolean" + }, + "undo": { + "type": "boolean" + }, + "unified_exec": { + "type": "boolean" + }, + "web_search": { + "type": "boolean" + }, + "web_search_cached": { + "type": "boolean" + }, + "web_search_request": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "feedback": { + "description": "When `false`, disables feedback collection across Codex product surfaces. Defaults to `true`.", + "allOf": [ + { + "$ref": "#/definitions/FeedbackConfigToml" + } + ] + }, + "file_opener": { + "description": "Optional URI-based file opener. If set, citations to files in the model output will be hyperlinked using the specified URI scheme.", + "allOf": [ + { + "$ref": "#/definitions/UriBasedFileOpener" + } + ] + }, + "forced_chatgpt_workspace_id": { + "description": "When set, restricts ChatGPT login to a specific workspace identifier.", + "default": null, + "type": "string" + }, + "forced_login_method": { + "description": "When set, restricts the login mechanism users may use.", + "default": null, + "allOf": [ + { + "$ref": "#/definitions/ForcedLoginMethod" + } + ] + }, + "ghost_snapshot": { + "description": "Settings for ghost snapshots (used for undo).", + "default": null, + "allOf": [ + { + "$ref": "#/definitions/GhostSnapshotToml" + } + ] + }, + "hide_agent_reasoning": { + "description": "When set to `true`, `AgentReasoning` events will be hidden from the UI/output. Defaults to `false`.", + "type": "boolean" + }, + "history": { + "description": "Settings that govern if and what will be written to `~/.codex/history.jsonl`.", + "default": null, + "allOf": [ + { + "$ref": "#/definitions/History" + } + ] + }, + "instructions": { + "description": "System instructions.", + "type": "string" + }, + "mcp_oauth_callback_port": { + "description": "Optional fixed port for the local HTTP callback server used during MCP OAuth login. When unset, Codex will bind to an ephemeral port chosen by the OS.", + "type": "integer", + "format": "uint16", + "minimum": 0.0 + }, + "mcp_oauth_credentials_store": { + "description": "Preferred backend for storing MCP OAuth credentials. keyring: Use an OS-specific keyring service. https://github.com/openai/codex/blob/main/codex-rs/rmcp-client/src/oauth.rs#L2 file: Use a file in the Codex home directory. auto (default): Use the OS-specific keyring service if available, otherwise use a file.", + "default": null, + "allOf": [ + { + "$ref": "#/definitions/OAuthCredentialsStoreMode" + } + ] + }, + "mcp_servers": { + "description": "Definition for MCP servers that Codex can reach out to for tool calls.", + "default": {}, + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/RawMcpServerConfig" + } + }, + "model": { + "description": "Optional override of model selection.", + "type": "string" + }, + "model_auto_compact_token_limit": { + "description": "Token usage threshold triggering auto-compaction of conversation history.", + "type": "integer", + "format": "int64" + }, + "model_context_window": { + "description": "Size of the context window for the model, in tokens.", + "type": "integer", + "format": "int64" + }, + "model_provider": { + "description": "Provider to use from the model_providers map.", + "type": "string" + }, + "model_providers": { + "description": "User-defined provider entries that extend/override the built-in list.", + "default": {}, + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/ModelProviderInfo" + } + }, + "model_reasoning_effort": { + "$ref": "#/definitions/ReasoningEffort" + }, + "model_reasoning_summary": { + "$ref": "#/definitions/ReasoningSummary" + }, + "model_supports_reasoning_summaries": { + "description": "Override to force-enable reasoning summaries for the configured model.", + "type": "boolean" + }, + "model_verbosity": { + "description": "Optional verbosity control for GPT-5 models (Responses API `text.verbosity`).", + "allOf": [ + { + "$ref": "#/definitions/Verbosity" + } + ] + }, + "notice": { + "description": "Collection of in-product notices (different from notifications) See [`crate::config::types::Notices`] for more details", + "allOf": [ + { + "$ref": "#/definitions/Notice" + } + ] + }, + "notify": { + "description": "Optional external command to spawn for end-user notifications.", + "default": null, + "type": "array", + "items": { + "type": "string" + } + }, + "oss_provider": { + "description": "Preferred OSS provider for local models, e.g. \"lmstudio\", \"ollama\", or \"ollama-chat\".", + "type": "string" + }, + "otel": { + "description": "OTEL configuration.", + "allOf": [ + { + "$ref": "#/definitions/OtelConfigToml" + } + ] + }, + "profile": { + "description": "Profile to use from the `profiles` map.", + "type": "string" + }, + "profiles": { + "description": "Named profiles to facilitate switching between different configurations.", + "default": {}, + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/ConfigProfile" + } + }, + "project_doc_fallback_filenames": { + "description": "Ordered list of fallback filenames to look for when AGENTS.md is missing.", + "type": "array", + "items": { + "type": "string" + } + }, + "project_doc_max_bytes": { + "description": "Maximum number of bytes to include from an AGENTS.md project doc file.", + "type": "integer", + "format": "uint", + "minimum": 0.0 + }, + "project_root_markers": { + "description": "Markers used to detect the project root when searching parent directories for `.codex` folders. Defaults to [\".git\"] when unset.", + "default": null, + "type": "array", + "items": { + "type": "string" + } + }, + "projects": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/ProjectConfig" + } + }, + "review_model": { + "description": "Review model override used by the `/review` feature.", + "type": "string" + }, + "sandbox_mode": { + "description": "Sandbox mode to use.", + "allOf": [ + { + "$ref": "#/definitions/SandboxMode" + } + ] + }, + "sandbox_workspace_write": { + "description": "Sandbox configuration to apply if `sandbox` is `WorkspaceWrite`.", + "allOf": [ + { + "$ref": "#/definitions/SandboxWorkspaceWrite" + } + ] + }, + "shell_environment_policy": { + "default": { + "exclude": null, + "experimental_use_profile": null, + "ignore_default_excludes": null, + "include_only": null, + "inherit": null, + "set": null + }, + "allOf": [ + { + "$ref": "#/definitions/ShellEnvironmentPolicyToml" + } + ] + }, + "show_raw_agent_reasoning": { + "description": "When set to `true`, `AgentReasoningRawContentEvent` events will be shown in the UI/output. Defaults to `false`.", + "type": "boolean" + }, + "tool_output_token_limit": { + "description": "Token budget applied when storing tool/function outputs in the context manager.", + "type": "integer", + "format": "uint", + "minimum": 0.0 + }, + "tools": { + "description": "Nested tools section for feature toggles", + "allOf": [ + { + "$ref": "#/definitions/ToolsToml" + } + ] + }, + "tui": { + "description": "Collection of settings that are specific to the TUI.", + "allOf": [ + { + "$ref": "#/definitions/Tui" + } + ] + }, + "windows_wsl_setup_acknowledged": { + "description": "Tracks whether the Windows onboarding screen has been acknowledged.", + "type": "boolean" + } + }, + "additionalProperties": false, + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "AltScreenMode": { + "description": "Controls whether the TUI uses the terminal's alternate screen buffer.\n\n**Background:** The alternate screen buffer provides a cleaner fullscreen experience without polluting the terminal's scrollback history. However, it conflicts with terminal multiplexers like Zellij that strictly follow the xterm specification, which defines that alternate screen buffers should not have scrollback.\n\n**Zellij's behavior:** Zellij intentionally disables scrollback in alternate screen mode (see https://github.com/zellij-org/zellij/pull/1032) to comply with the xterm spec. This is by design and not configurable in Zellij—there is no option to enable scrollback in alternate screen mode.\n\n**Solution:** This setting provides a pragmatic workaround: - `auto` (default): Automatically detect the terminal multiplexer. If running in Zellij, disable alternate screen to preserve scrollback. Enable it everywhere else. - `always`: Always use alternate screen mode (original behavior before this fix). - `never`: Never use alternate screen mode. Runs in inline mode, preserving scrollback in all multiplexers.\n\nThe CLI flag `--no-alt-screen` can override this setting at runtime.", + "oneOf": [ + { + "description": "Auto-detect: disable alternate screen in Zellij, enable elsewhere.", + "type": "string", + "enum": [ + "auto" + ] + }, + { + "description": "Always use alternate screen (original behavior).", + "type": "string", + "enum": [ + "always" + ] + }, + { + "description": "Never use alternate screen (inline mode only).", + "type": "string", + "enum": [ + "never" + ] + } + ] + }, + "AnalyticsConfigToml": { + "description": "Analytics settings loaded from config.toml. Fields are optional so we can apply defaults.", + "type": "object", + "properties": { + "enabled": { + "description": "When `false`, disables analytics across Codex product surfaces in this profile.", + "type": "boolean" + } + }, + "additionalProperties": false + }, + "AskForApproval": { + "description": "Determines the conditions under which the user is consulted to approve running the command proposed by Codex.", + "oneOf": [ + { + "description": "Under this policy, only \"known safe\" commands—as determined by `is_safe_command()`—that **only read files** are auto‑approved. Everything else will ask the user to approve.", + "type": "string", + "enum": [ + "untrusted" + ] + }, + { + "description": "*All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox.", + "type": "string", + "enum": [ + "on-failure" + ] + }, + { + "description": "The model decides when to ask the user for approval.", + "type": "string", + "enum": [ + "on-request" + ] + }, + { + "description": "Never ask the user to approve commands. Failures are immediately returned to the model, and never escalated to the user for approval.", + "type": "string", + "enum": [ + "never" + ] + } + ] + }, + "AuthCredentialsStoreMode": { + "description": "Determine where Codex should store CLI auth credentials.", + "oneOf": [ + { + "description": "Persist credentials in CODEX_HOME/auth.json.", + "type": "string", + "enum": [ + "file" + ] + }, + { + "description": "Persist credentials in the keyring. Fail if unavailable.", + "type": "string", + "enum": [ + "keyring" + ] + }, + { + "description": "Use keyring when available; otherwise, fall back to a file in CODEX_HOME.", + "type": "string", + "enum": [ + "auto" + ] + } + ] + }, + "ConfigProfile": { + "description": "Collection of common configuration options that a user can define as a unit in `config.toml`.", + "type": "object", + "properties": { + "analytics": { + "$ref": "#/definitions/AnalyticsConfigToml" + }, + "approval_policy": { + "$ref": "#/definitions/AskForApproval" + }, + "chatgpt_base_url": { + "type": "string" + }, + "experimental_compact_prompt_file": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "experimental_instructions_file": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "experimental_use_freeform_apply_patch": { + "type": "boolean" + }, + "experimental_use_unified_exec_tool": { + "type": "boolean" + }, + "features": { + "description": "Optional feature toggles scoped to this profile.", + "default": null, + "type": "object", + "properties": { + "apply_patch_freeform": { + "type": "boolean" + }, + "collab": { + "type": "boolean" + }, + "elevated_windows_sandbox": { + "type": "boolean" + }, + "enable_experimental_windows_sandbox": { + "type": "boolean" + }, + "enable_request_compression": { + "type": "boolean" + }, + "exec_policy": { + "type": "boolean" + }, + "experimental_use_freeform_apply_patch": { + "type": "boolean" + }, + "experimental_use_unified_exec_tool": { + "type": "boolean" + }, + "experimental_windows_sandbox": { + "type": "boolean" + }, + "hierarchical_agents": { + "type": "boolean" + }, + "include_apply_patch_tool": { + "type": "boolean" + }, + "powershell_utf8": { + "type": "boolean" + }, + "remote_compaction": { + "type": "boolean" + }, + "remote_models": { + "type": "boolean" + }, + "shell_snapshot": { + "type": "boolean" + }, + "shell_tool": { + "type": "boolean" + }, + "steer": { + "type": "boolean" + }, + "tui2": { + "type": "boolean" + }, + "undo": { + "type": "boolean" + }, + "unified_exec": { + "type": "boolean" + }, + "web_search": { + "type": "boolean" + }, + "web_search_cached": { + "type": "boolean" + }, + "web_search_request": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "include_apply_patch_tool": { + "type": "boolean" + }, + "model": { + "type": "string" + }, + "model_provider": { + "description": "The key in the `model_providers` map identifying the [`ModelProviderInfo`] to use.", + "type": "string" + }, + "model_reasoning_effort": { + "$ref": "#/definitions/ReasoningEffort" + }, + "model_reasoning_summary": { + "$ref": "#/definitions/ReasoningSummary" + }, + "model_verbosity": { + "$ref": "#/definitions/Verbosity" + }, + "oss_provider": { + "type": "string" + }, + "sandbox_mode": { + "$ref": "#/definitions/SandboxMode" + }, + "tools_view_image": { + "type": "boolean" + }, + "tools_web_search": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "FeedbackConfigToml": { + "type": "object", + "properties": { + "enabled": { + "description": "When `false`, disables the feedback flow across Codex product surfaces.", + "type": "boolean" + } + }, + "additionalProperties": false + }, + "ForcedLoginMethod": { + "type": "string", + "enum": [ + "chatgpt", + "api" + ] + }, + "GhostSnapshotToml": { + "type": "object", + "properties": { + "disable_warnings": { + "description": "Disable all ghost snapshot warning events.", + "type": "boolean" + }, + "ignore_large_untracked_dirs": { + "description": "Ignore untracked directories that contain this many files or more. (Still emits a warning unless warnings are disabled.)", + "type": "integer", + "format": "int64" + }, + "ignore_large_untracked_files": { + "description": "Exclude untracked files larger than this many bytes from ghost snapshots.", + "type": "integer", + "format": "int64" + } + }, + "additionalProperties": false + }, + "History": { + "description": "Settings that govern if and what will be written to `~/.codex/history.jsonl`.", + "type": "object", + "required": [ + "persistence" + ], + "properties": { + "max_bytes": { + "description": "If set, the maximum size of the history file in bytes. The oldest entries are dropped once the file exceeds this limit.", + "type": "integer", + "format": "uint", + "minimum": 0.0 + }, + "persistence": { + "description": "If true, history entries will not be written to disk.", + "allOf": [ + { + "$ref": "#/definitions/HistoryPersistence" + } + ] + } + }, + "additionalProperties": false + }, + "HistoryPersistence": { + "oneOf": [ + { + "description": "Save all history entries to disk.", + "type": "string", + "enum": [ + "save-all" + ] + }, + { + "description": "Do not write history to disk.", + "type": "string", + "enum": [ + "none" + ] + } + ] + }, + "ModelProviderInfo": { + "description": "Serializable representation of a provider definition.", + "type": "object", + "required": [ + "name" + ], + "properties": { + "base_url": { + "description": "Base URL for the provider's OpenAI-compatible API.", + "type": "string" + }, + "env_http_headers": { + "description": "Optional HTTP headers to include in requests to this provider where the (key, value) pairs are the header name and _environment variable_ whose value should be used. If the environment variable is not set, or the value is empty, the header will not be included in the request.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "env_key": { + "description": "Environment variable that stores the user's API key for this provider.", + "type": "string" + }, + "env_key_instructions": { + "description": "Optional instructions to help the user get a valid value for the variable and set it.", + "type": "string" + }, + "experimental_bearer_token": { + "description": "Value to use with `Authorization: Bearer ` header. Use of this config is discouraged in favor of `env_key` for security reasons, but this may be necessary when using this programmatically.", + "type": "string" + }, + "http_headers": { + "description": "Additional HTTP headers to include in requests to this provider where the (key, value) pairs are the header name and value.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "name": { + "description": "Friendly display name.", + "type": "string" + }, + "query_params": { + "description": "Optional query parameters to append to the base URL.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "request_max_retries": { + "description": "Maximum number of times to retry a failed HTTP request to this provider.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "requires_openai_auth": { + "description": "Does this provider require an OpenAI API Key or ChatGPT login token? If true, user is presented with login screen on first run, and login preference and token/key are stored in auth.json. If false (which is the default), login screen is skipped, and API key (if needed) comes from the \"env_key\" environment variable.", + "default": false, + "type": "boolean" + }, + "stream_idle_timeout_ms": { + "description": "Idle timeout (in milliseconds) to wait for activity on a streaming response before treating the connection as lost.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "stream_max_retries": { + "description": "Number of times to retry reconnecting a dropped streaming response before failing.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "wire_api": { + "description": "Which wire protocol this provider expects.", + "default": "chat", + "allOf": [ + { + "$ref": "#/definitions/WireApi" + } + ] + } + }, + "additionalProperties": false + }, + "Notice": { + "description": "Settings for notices we display to users via the tui and app-server clients (primarily the Codex IDE extension). NOTE: these are different from notifications - notices are warnings, NUX screens, acknowledgements, etc.", + "type": "object", + "properties": { + "hide_full_access_warning": { + "description": "Tracks whether the user has acknowledged the full access warning prompt.", + "type": "boolean" + }, + "hide_gpt-5.1-codex-max_migration_prompt": { + "description": "Tracks whether the user has seen the gpt-5.1-codex-max migration prompt", + "type": "boolean" + }, + "hide_gpt5_1_migration_prompt": { + "description": "Tracks whether the user has seen the model migration prompt", + "type": "boolean" + }, + "hide_rate_limit_model_nudge": { + "description": "Tracks whether the user opted out of the rate limit model switch reminder.", + "type": "boolean" + }, + "hide_world_writable_warning": { + "description": "Tracks whether the user has acknowledged the Windows world-writable directories warning.", + "type": "boolean" + }, + "model_migrations": { + "description": "Tracks acknowledged model migrations as old->new model slug mappings.", + "default": {}, + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "Notifications": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "OAuthCredentialsStoreMode": { + "description": "Determine where Codex should store and read MCP credentials.", + "oneOf": [ + { + "description": "`Keyring` when available; otherwise, `File`. Credentials stored in the keyring will only be readable by Codex unless the user explicitly grants access via OS-level keyring access.", + "type": "string", + "enum": [ + "auto" + ] + }, + { + "description": "CODEX_HOME/.credentials.json This file will be readable to Codex and other applications running as the same user.", + "type": "string", + "enum": [ + "file" + ] + }, + { + "description": "Keyring when available, otherwise fail.", + "type": "string", + "enum": [ + "keyring" + ] + } + ] + }, + "OtelConfigToml": { + "description": "OTEL settings loaded from config.toml. Fields are optional so we can apply defaults.", + "type": "object", + "properties": { + "environment": { + "description": "Mark traces with environment (dev, staging, prod, test). Defaults to dev.", + "type": "string" + }, + "exporter": { + "description": "Optional log exporter", + "allOf": [ + { + "$ref": "#/definitions/OtelExporterKind" + } + ] + }, + "log_user_prompt": { + "description": "Log user prompt in traces", + "type": "boolean" + }, + "trace_exporter": { + "description": "Optional trace exporter", + "allOf": [ + { + "$ref": "#/definitions/OtelExporterKind" + } + ] + } + }, + "additionalProperties": false + }, + "OtelExporterKind": { + "description": "Which OTEL exporter to use.", + "oneOf": [ + { + "type": "string", + "enum": [ + "none", + "statsig" + ] + }, + { + "type": "object", + "required": [ + "otlp-http" + ], + "properties": { + "otlp-http": { + "type": "object", + "required": [ + "endpoint", + "protocol" + ], + "properties": { + "endpoint": { + "type": "string" + }, + "headers": { + "default": {}, + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "protocol": { + "$ref": "#/definitions/OtelHttpProtocol" + }, + "tls": { + "default": null, + "allOf": [ + { + "$ref": "#/definitions/OtelTlsConfig" + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "otlp-grpc" + ], + "properties": { + "otlp-grpc": { + "type": "object", + "required": [ + "endpoint" + ], + "properties": { + "endpoint": { + "type": "string" + }, + "headers": { + "default": {}, + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "tls": { + "default": null, + "allOf": [ + { + "$ref": "#/definitions/OtelTlsConfig" + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "OtelHttpProtocol": { + "oneOf": [ + { + "description": "Binary payload", + "type": "string", + "enum": [ + "binary" + ] + }, + { + "description": "JSON payload", + "type": "string", + "enum": [ + "json" + ] + } + ] + }, + "OtelTlsConfig": { + "type": "object", + "properties": { + "ca-certificate": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "client-certificate": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "client-private-key": { + "$ref": "#/definitions/AbsolutePathBuf" + } + }, + "additionalProperties": false + }, + "ProjectConfig": { + "type": "object", + "properties": { + "trust_level": { + "$ref": "#/definitions/TrustLevel" + } + }, + "additionalProperties": false + }, + "RawMcpServerConfig": { + "type": "object", + "properties": { + "args": { + "default": null, + "type": "array", + "items": { + "type": "string" + } + }, + "bearer_token": { + "type": "string" + }, + "bearer_token_env_var": { + "type": "string" + }, + "command": { + "type": "string" + }, + "cwd": { + "default": null, + "type": "string" + }, + "disabled_tools": { + "default": null, + "type": "array", + "items": { + "type": "string" + } + }, + "enabled": { + "default": null, + "type": "boolean" + }, + "enabled_tools": { + "default": null, + "type": "array", + "items": { + "type": "string" + } + }, + "env": { + "default": null, + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "env_http_headers": { + "default": null, + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "env_vars": { + "default": null, + "type": "array", + "items": { + "type": "string" + } + }, + "http_headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "startup_timeout_ms": { + "default": null, + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "startup_timeout_sec": { + "default": null, + "type": "number", + "format": "double" + }, + "tool_timeout_sec": { + "default": null, + "type": "number", + "format": "double" + }, + "url": { + "type": "string" + } + }, + "additionalProperties": false + }, + "ReasoningEffort": { + "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", + "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "ReasoningSummary": { + "description": "A summary of the reasoning performed by the model. This can be useful for debugging and understanding the model's reasoning process. See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#reasoning-summaries", + "oneOf": [ + { + "type": "string", + "enum": [ + "auto", + "concise", + "detailed" + ] + }, + { + "description": "Option to disable reasoning summaries.", + "type": "string", + "enum": [ + "none" + ] + } + ] + }, + "SandboxMode": { + "type": "string", + "enum": [ + "read-only", + "workspace-write", + "danger-full-access" + ] + }, + "SandboxWorkspaceWrite": { + "type": "object", + "properties": { + "exclude_slash_tmp": { + "default": false, + "type": "boolean" + }, + "exclude_tmpdir_env_var": { + "default": false, + "type": "boolean" + }, + "network_access": { + "default": false, + "type": "boolean" + }, + "writable_roots": { + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + } + } + }, + "additionalProperties": false + }, + "ScrollInputMode": { + "description": "How TUI2 should interpret mouse scroll events.\n\nTerminals generally encode both mouse wheels and trackpads as the same \"scroll up/down\" mouse button events, without a magnitude. This setting controls whether Codex uses a heuristic to infer wheel vs trackpad per stream, or forces a specific behavior.", + "oneOf": [ + { + "description": "Infer wheel vs trackpad behavior per scroll stream.", + "type": "string", + "enum": [ + "auto" + ] + }, + { + "description": "Always treat scroll events as mouse-wheel input (fixed lines per tick).", + "type": "string", + "enum": [ + "wheel" + ] + }, + { + "description": "Always treat scroll events as trackpad input (fractional accumulation).", + "type": "string", + "enum": [ + "trackpad" + ] + } + ] + }, + "ShellEnvironmentPolicyInherit": { + "oneOf": [ + { + "description": "\"Core\" environment variables for the platform. On UNIX, this would include HOME, LOGNAME, PATH, SHELL, and USER, among others.", + "type": "string", + "enum": [ + "core" + ] + }, + { + "description": "Inherits the full environment from the parent process.", + "type": "string", + "enum": [ + "all" + ] + }, + { + "description": "Do not inherit any environment variables from the parent process.", + "type": "string", + "enum": [ + "none" + ] + } + ] + }, + "ShellEnvironmentPolicyToml": { + "description": "Policy for building the `env` when spawning a process via either the `shell` or `local_shell` tool.", + "type": "object", + "properties": { + "exclude": { + "description": "List of regular expressions.", + "type": "array", + "items": { + "type": "string" + } + }, + "experimental_use_profile": { + "type": "boolean" + }, + "ignore_default_excludes": { + "type": "boolean" + }, + "include_only": { + "description": "List of regular expressions.", + "type": "array", + "items": { + "type": "string" + } + }, + "inherit": { + "$ref": "#/definitions/ShellEnvironmentPolicyInherit" + }, + "set": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "ToolsToml": { + "type": "object", + "properties": { + "view_image": { + "description": "Enable the `view_image` tool that lets the agent attach local images.", + "default": null, + "type": "boolean" + }, + "web_search": { + "default": null, + "type": "boolean" + } + }, + "additionalProperties": false + }, + "TrustLevel": { + "description": "Represents the trust level for a project directory. This determines the approval policy and sandbox mode applied.", + "type": "string", + "enum": [ + "trusted", + "untrusted" + ] + }, + "Tui": { + "description": "Collection of settings that are specific to the TUI.", + "type": "object", + "properties": { + "alternate_screen": { + "description": "Controls whether the TUI uses the terminal's alternate screen buffer.\n\n- `auto` (default): Disable alternate screen in Zellij, enable elsewhere. - `always`: Always use alternate screen (original behavior). - `never`: Never use alternate screen (inline mode only, preserves scrollback).\n\nUsing alternate screen provides a cleaner fullscreen experience but prevents scrollback in terminal multiplexers like Zellij that follow the xterm spec.", + "default": "auto", + "allOf": [ + { + "$ref": "#/definitions/AltScreenMode" + } + ] + }, + "animations": { + "description": "Enable animations (welcome screen, shimmer effects, spinners). Defaults to `true`.", + "default": true, + "type": "boolean" + }, + "notifications": { + "description": "Enable desktop notifications from the TUI when the terminal is unfocused. Defaults to `true`.", + "default": true, + "allOf": [ + { + "$ref": "#/definitions/Notifications" + } + ] + }, + "scroll_events_per_tick": { + "description": "Override the *wheel* event density used to normalize TUI2 scrolling.\n\nTerminals generally deliver both mouse wheels and trackpads as discrete `scroll up/down` mouse events with direction but no magnitude. Unfortunately, the *number* of raw events per physical wheel notch varies by terminal (commonly 1, 3, or 9+). TUI2 uses this value to normalize that raw event density into consistent \"wheel tick\" behavior.\n\nWheel math (conceptually):\n\n- A single event contributes `1 / scroll_events_per_tick` tick-equivalents. - Wheel-like streams then scale that by `scroll_wheel_lines` so one physical notch scrolls a fixed number of lines.\n\nTrackpad math is intentionally *not* fully tied to this value: in trackpad-like mode, TUI2 uses `min(scroll_events_per_tick, 3)` as the divisor so terminals with dense wheel ticks (e.g. 9 events per notch) do not make trackpads feel artificially slow.\n\nDefaults are derived per terminal from [`crate::terminal::TerminalInfo`] when TUI2 starts. See `codex-rs/tui2/docs/scroll_input_model.md` for the probe data and rationale.", + "type": "integer", + "format": "uint16", + "minimum": 0.0 + }, + "scroll_invert": { + "description": "Invert mouse scroll direction in TUI2.\n\nThis flips the scroll sign after terminal detection. It is applied consistently to both wheel and trackpad input.", + "default": false, + "type": "boolean" + }, + "scroll_mode": { + "description": "Select how TUI2 interprets mouse scroll input.\n\n- `auto` (default): infer wheel vs trackpad per scroll stream. - `wheel`: always use wheel behavior (fixed lines per wheel notch). - `trackpad`: always use trackpad behavior (fractional accumulation; wheel may feel slow).", + "default": "auto", + "allOf": [ + { + "$ref": "#/definitions/ScrollInputMode" + } + ] + }, + "scroll_trackpad_accel_events": { + "description": "Trackpad acceleration: approximate number of events required to gain +1x speed in TUI2.\n\nThis keeps small swipes precise while allowing large/faster swipes to cover more content. Defaults are chosen to address terminals where trackpad event density is comparatively low.\n\nConcretely, TUI2 computes an acceleration multiplier for trackpad-like streams:\n\n- `multiplier = clamp(1 + abs(events) / scroll_trackpad_accel_events, 1..scroll_trackpad_accel_max)`\n\nThe multiplier is applied to the stream’s computed line delta (including any carried fractional remainder).", + "type": "integer", + "format": "uint16", + "minimum": 0.0 + }, + "scroll_trackpad_accel_max": { + "description": "Trackpad acceleration: maximum multiplier applied to trackpad-like streams.\n\nSet to 1 to effectively disable trackpad acceleration.\n\nSee [`Tui::scroll_trackpad_accel_events`] for the exact multiplier formula.", + "type": "integer", + "format": "uint16", + "minimum": 0.0 + }, + "scroll_trackpad_lines": { + "description": "Override baseline trackpad scroll sensitivity in TUI2.\n\nTrackpads do not have discrete notches, but terminals still emit discrete `scroll up/down` events. In trackpad-like mode, TUI2 accumulates fractional scroll and only applies whole lines to the viewport.\n\nTrackpad per-event contribution is:\n\n- `scroll_trackpad_lines / min(scroll_events_per_tick, 3)`\n\n(plus optional bounded acceleration; see `scroll_trackpad_accel_*`). The `min(..., 3)` divisor is deliberate: `scroll_events_per_tick` is calibrated from *wheel* behavior and can be much larger than trackpad event density, which would otherwise make trackpads feel too slow in dense-wheel terminals.\n\nDefaults to 1, meaning one tick-equivalent maps to one transcript line.", + "type": "integer", + "format": "uint16", + "minimum": 0.0 + }, + "scroll_wheel_like_max_duration_ms": { + "description": "Auto-mode fallback: maximum duration (ms) that a very small stream is still treated as wheel-like.\n\nThis is only used when `scroll_events_per_tick` is effectively 1 (one event per wheel notch). In that case, we cannot observe a \"tick completion time\", so TUI2 treats a short-lived, small stream (<= 2 events) as wheel-like to preserve classic wheel behavior.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "scroll_wheel_lines": { + "description": "Override how many transcript lines one physical *wheel notch* should scroll in TUI2.\n\nThis is the \"classic feel\" knob. Defaults to 3.\n\nWheel-like per-event contribution is `scroll_wheel_lines / scroll_events_per_tick`. For example, in a terminal that emits 9 events per notch, the default `3 / 9` yields 1/3 of a line per event and totals 3 lines once the full notch burst arrives.\n\nSee `codex-rs/tui2/docs/scroll_input_model.md` for details on the stream model and the wheel/trackpad heuristic.", + "type": "integer", + "format": "uint16", + "minimum": 0.0 + }, + "scroll_wheel_tick_detect_max_ms": { + "description": "Auto-mode threshold: maximum time (ms) for the first tick-worth of events to arrive.\n\nIn `scroll_mode = \"auto\"`, TUI2 starts a stream as trackpad-like (to avoid overshoot) and promotes it to wheel-like if `scroll_events_per_tick` events arrive \"quickly enough\". This threshold controls what \"quickly enough\" means.\n\nMost users should leave this unset; it is primarily for terminals that emit wheel ticks batched over longer time spans.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "show_tooltips": { + "description": "Show startup tooltips in the TUI welcome screen. Defaults to `true`.", + "default": true, + "type": "boolean" + } + }, + "additionalProperties": false + }, + "UriBasedFileOpener": { + "oneOf": [ + { + "type": "string", + "enum": [ + "vscode", + "vscode-insiders", + "windsurf", + "cursor" + ] + }, + { + "description": "Option to disable the URI-based file opener.", + "type": "string", + "enum": [ + "none" + ] + } + ] + }, + "Verbosity": { + "description": "Controls output length/detail on GPT-5 models via the Responses API. Serialized with lowercase values to match the OpenAI API.", + "type": "string", + "enum": [ + "low", + "medium", + "high" + ] + }, + "WireApi": { + "description": "Wire protocol that the provider speaks. Most third-party services only implement the classic OpenAI Chat Completions JSON schema, whereas OpenAI itself (and a handful of others) additionally expose the more modern *Responses* API. The two protocols use different request/response shapes and *cannot* be auto-detected at runtime, therefore each provider entry must declare which one it expects.", + "oneOf": [ + { + "description": "The Responses API exposed by OpenAI at `/v1/responses`.", + "type": "string", + "enum": [ + "responses" + ] + }, + { + "description": "Experimental: Responses API over WebSocket transport.", + "type": "string", + "enum": [ + "responses_websocket" + ] + }, + { + "description": "Regular Chat Completions compatible with `/v1/chat/completions`.", + "type": "string", + "enum": [ + "chat" + ] + } + ] + } + } +} \ No newline at end of file diff --git a/codex-rs/core/gpt-5.1-codex-max_prompt.md b/codex-rs/core/gpt-5.1-codex-max_prompt.md index a8227c893f..8e3f08fb51 100644 --- a/codex-rs/core/gpt-5.1-codex-max_prompt.md +++ b/codex-rs/core/gpt-5.1-codex-max_prompt.md @@ -25,43 +25,6 @@ When using the planning tool: - Do not make single-step plans. - When you made a plan, update it after having performed one of the sub-tasks that you shared on the plan. -## Codex CLI harness, sandboxing, and approvals - -The Codex CLI harness supports several different configurations for sandboxing and escalation approvals that the user can choose from. - -Filesystem sandboxing defines which files can be read or written. The options for `sandbox_mode` are: -- **read-only**: The sandbox only permits reading files. -- **workspace-write**: The sandbox permits reading files, and editing files in `cwd` and `writable_roots`. Editing files in other directories requires approval. -- **danger-full-access**: No filesystem sandboxing - all commands are permitted. - -Network sandboxing defines whether network can be accessed without approval. Options for `network_access` are: -- **restricted**: Requires approval -- **enabled**: No approval needed - -Approvals are your mechanism to get user consent to run shell commands without the sandbox. Possible configuration options for `approval_policy` are -- **untrusted**: The harness will escalate most commands for user approval, apart from a limited allowlist of safe "read" commands. -- **on-failure**: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox. -- **on-request**: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. (Note that this mode is not always available. If it is, you'll see parameters for it in the `shell` command description.) -- **never**: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is paired with `danger-full-access`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding. - -When you are running with `approval_policy == on-request`, and sandboxing enabled, here are scenarios where you'll need to request approval: -- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /var) -- You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files. -- You are running sandboxed and need to run a command that requires network access (e.g. installing packages) -- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `sandbox_permissions` and `justification` parameters - do not message the user before requesting approval for the command. -- You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for -- (for all of these, you should weigh alternative paths that do not require approval) - -When `sandbox_mode` is set to read-only, you'll need to request approval for any command that isn't a read. - -You will be told what filesystem sandboxing, network sandboxing, and approval mode are active in a developer or user message. If you are not told about this, assume that you are running with workspace-write, network sandboxing enabled, and approval on-failure. - -Although they introduce friction to the user because your work is paused until the user responds, you should leverage them when necessary to accomplish important work. If the completing the task requires escalated permissions, Do not let these settings or the sandbox deter you from attempting to accomplish the user's task unless it is set to "never", in which case never ask for approvals. - -When requesting approval to execute a command that will require escalated privileges: - - Provide the `sandbox_permissions` parameter with the value `"require_escalated"` - - Include a short, 1 sentence explanation for why you need escalated permissions in the justification parameter - ## Special user requests - If the user makes a simple request (such as asking for the time) which you can fulfill by running a terminal command (such as `date`), you should do so. diff --git a/codex-rs/core/gpt-5.2-codex_prompt.md b/codex-rs/core/gpt-5.2-codex_prompt.md index a8227c893f..8e3f08fb51 100644 --- a/codex-rs/core/gpt-5.2-codex_prompt.md +++ b/codex-rs/core/gpt-5.2-codex_prompt.md @@ -25,43 +25,6 @@ When using the planning tool: - Do not make single-step plans. - When you made a plan, update it after having performed one of the sub-tasks that you shared on the plan. -## Codex CLI harness, sandboxing, and approvals - -The Codex CLI harness supports several different configurations for sandboxing and escalation approvals that the user can choose from. - -Filesystem sandboxing defines which files can be read or written. The options for `sandbox_mode` are: -- **read-only**: The sandbox only permits reading files. -- **workspace-write**: The sandbox permits reading files, and editing files in `cwd` and `writable_roots`. Editing files in other directories requires approval. -- **danger-full-access**: No filesystem sandboxing - all commands are permitted. - -Network sandboxing defines whether network can be accessed without approval. Options for `network_access` are: -- **restricted**: Requires approval -- **enabled**: No approval needed - -Approvals are your mechanism to get user consent to run shell commands without the sandbox. Possible configuration options for `approval_policy` are -- **untrusted**: The harness will escalate most commands for user approval, apart from a limited allowlist of safe "read" commands. -- **on-failure**: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox. -- **on-request**: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. (Note that this mode is not always available. If it is, you'll see parameters for it in the `shell` command description.) -- **never**: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is paired with `danger-full-access`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding. - -When you are running with `approval_policy == on-request`, and sandboxing enabled, here are scenarios where you'll need to request approval: -- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /var) -- You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files. -- You are running sandboxed and need to run a command that requires network access (e.g. installing packages) -- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `sandbox_permissions` and `justification` parameters - do not message the user before requesting approval for the command. -- You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for -- (for all of these, you should weigh alternative paths that do not require approval) - -When `sandbox_mode` is set to read-only, you'll need to request approval for any command that isn't a read. - -You will be told what filesystem sandboxing, network sandboxing, and approval mode are active in a developer or user message. If you are not told about this, assume that you are running with workspace-write, network sandboxing enabled, and approval on-failure. - -Although they introduce friction to the user because your work is paused until the user responds, you should leverage them when necessary to accomplish important work. If the completing the task requires escalated permissions, Do not let these settings or the sandbox deter you from attempting to accomplish the user's task unless it is set to "never", in which case never ask for approvals. - -When requesting approval to execute a command that will require escalated privileges: - - Provide the `sandbox_permissions` parameter with the value `"require_escalated"` - - Include a short, 1 sentence explanation for why you need escalated permissions in the justification parameter - ## Special user requests - If the user makes a simple request (such as asking for the time) which you can fulfill by running a terminal command (such as `date`), you should do so. diff --git a/codex-rs/core/gpt_5_1_prompt.md b/codex-rs/core/gpt_5_1_prompt.md index a4492c6acb..440422ae6a 100644 --- a/codex-rs/core/gpt_5_1_prompt.md +++ b/codex-rs/core/gpt_5_1_prompt.md @@ -159,43 +159,6 @@ If completing the user's task requires writing or modifying files, your code and - Do not use one-letter variable names unless explicitly requested. - NEVER output inline citations like "【F:README.md†L5-L14】" in your outputs. The CLI is not able to render these so they will just be broken in the UI. Instead, if you output valid filepaths, users will be able to click on them to open the files in their editor. -## Codex CLI harness, sandboxing, and approvals - -The Codex CLI harness supports several different configurations for sandboxing and escalation approvals that the user can choose from. - -Filesystem sandboxing defines which files can be read or written. The options for `sandbox_mode` are: -- **read-only**: The sandbox only permits reading files. -- **workspace-write**: The sandbox permits reading files, and editing files in `cwd` and `writable_roots`. Editing files in other directories requires approval. -- **danger-full-access**: No filesystem sandboxing - all commands are permitted. - -Network sandboxing defines whether network can be accessed without approval. Options for `network_access` are: -- **restricted**: Requires approval -- **enabled**: No approval needed - -Approvals are your mechanism to get user consent to run shell commands without the sandbox. Possible configuration options for `approval_policy` are -- **untrusted**: The harness will escalate most commands for user approval, apart from a limited allowlist of safe "read" commands. -- **on-failure**: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox. -- **on-request**: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. (Note that this mode is not always available. If it is, you'll see parameters for escalating in the tool definition.) -- **never**: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is paired with `danger-full-access`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding. - -When you are running with `approval_policy == on-request`, and sandboxing enabled, here are scenarios where you'll need to request approval: -- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /var) -- You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files. -- You are running sandboxed and need to run a command that requires network access (e.g. installing packages) -- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `sandbox_permissions` and `justification` parameters. Within this harness, prefer requesting approval via the tool over asking in natural language. -- You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for -- (for all of these, you should weigh alternative paths that do not require approval) - -When `sandbox_mode` is set to read-only, you'll need to request approval for any command that isn't a read. - -You will be told what filesystem sandboxing, network sandboxing, and approval mode are active in a developer or user message. If you are not told about this, assume that you are running with workspace-write, network sandboxing enabled, and approval on-failure. - -Although they introduce friction to the user because your work is paused until the user responds, you should leverage them when necessary to accomplish important work. If the completing the task requires escalated permissions, Do not let these settings or the sandbox deter you from attempting to accomplish the user's task unless it is set to "never", in which case never ask for approvals. - -When requesting approval to execute a command that will require escalated privileges: - - Provide the `sandbox_permissions` parameter with the value `"require_escalated"` - - Include a short, 1 sentence explanation for why you need escalated permissions in the justification parameter - ## Validating your work If the codebase has tests or the ability to build or run, consider using them to verify changes once your work is complete. diff --git a/codex-rs/core/gpt_5_2_prompt.md b/codex-rs/core/gpt_5_2_prompt.md index cfbb220849..7dd684bf06 100644 --- a/codex-rs/core/gpt_5_2_prompt.md +++ b/codex-rs/core/gpt_5_2_prompt.md @@ -133,43 +133,6 @@ If completing the user's task requires writing or modifying files, your code and - Do not use one-letter variable names unless explicitly requested. - NEVER output inline citations like "【F:README.md†L5-L14】" in your outputs. The CLI is not able to render these so they will just be broken in the UI. Instead, if you output valid filepaths, users will be able to click on them to open the files in their editor. -## Codex CLI harness, sandboxing, and approvals - -The Codex CLI harness supports several different configurations for sandboxing and escalation approvals that the user can choose from. - -Filesystem sandboxing defines which files can be read or written. The options for `sandbox_mode` are: -- **read-only**: The sandbox only permits reading files. -- **workspace-write**: The sandbox permits reading files, and editing files in `cwd` and `writable_roots`. Editing files in other directories requires approval. -- **danger-full-access**: No filesystem sandboxing - all commands are permitted. - -Network sandboxing defines whether network can be accessed without approval. Options for `network_access` are: -- **restricted**: Requires approval -- **enabled**: No approval needed - -Approvals are your mechanism to get user consent to run shell commands without the sandbox. Possible configuration options for `approval_policy` are -- **untrusted**: The harness will escalate most commands for user approval, apart from a limited allowlist of safe "read" commands. -- **on-failure**: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox. -- **on-request**: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. (Note that this mode is not always available. If it is, you'll see parameters for escalating in the tool definition.) -- **never**: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is paired with `danger-full-access`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding. - -When you are running with `approval_policy == on-request`, and sandboxing enabled, here are scenarios where you'll need to request approval: -- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /var) -- You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files. -- You are running sandboxed and need to run a command that requires network access (e.g. installing packages) -- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `sandbox_permissions` and `justification` parameters - do not message the user before requesting approval for the command. -- You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for -- (for all of these, you should weigh alternative paths that do not require approval) - -When `sandbox_mode` is set to read-only, you'll need to request approval for any command that isn't a read. - -You will be told what filesystem sandboxing, network sandboxing, and approval mode are active in a developer or user message. If you are not told about this, assume that you are running with workspace-write, network sandboxing enabled, and approval on-failure. - -Although they introduce friction to the user because your work is paused until the user responds, you should leverage them when necessary to accomplish important work. If the completing the task requires escalated permissions, Do not let these settings or the sandbox deter you from attempting to accomplish the user's task unless it is set to "never", in which case never ask for approvals. - -When requesting approval to execute a command that will require escalated privileges: - - Provide the `sandbox_permissions` parameter with the value `"require_escalated"` - - Include a short, 1 sentence explanation for why you need escalated permissions in the justification parameter - ## Validating your work If the codebase has tests, or the ability to build or run tests, consider using them to verify changes once your work is complete. diff --git a/codex-rs/core/gpt_5_codex_prompt.md b/codex-rs/core/gpt_5_codex_prompt.md index e2f9017874..88a569fa72 100644 --- a/codex-rs/core/gpt_5_codex_prompt.md +++ b/codex-rs/core/gpt_5_codex_prompt.md @@ -25,43 +25,6 @@ When using the planning tool: - Do not make single-step plans. - When you made a plan, update it after having performed one of the sub-tasks that you shared on the plan. -## Codex CLI harness, sandboxing, and approvals - -The Codex CLI harness supports several different configurations for sandboxing and escalation approvals that the user can choose from. - -Filesystem sandboxing defines which files can be read or written. The options for `sandbox_mode` are: -- **read-only**: The sandbox only permits reading files. -- **workspace-write**: The sandbox permits reading files, and editing files in `cwd` and `writable_roots`. Editing files in other directories requires approval. -- **danger-full-access**: No filesystem sandboxing - all commands are permitted. - -Network sandboxing defines whether network can be accessed without approval. Options for `network_access` are: -- **restricted**: Requires approval -- **enabled**: No approval needed - -Approvals are your mechanism to get user consent to run shell commands without the sandbox. Possible configuration options for `approval_policy` are -- **untrusted**: The harness will escalate most commands for user approval, apart from a limited allowlist of safe "read" commands. -- **on-failure**: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox. -- **on-request**: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. (Note that this mode is not always available. If it is, you'll see parameters for it in the `shell` command description.) -- **never**: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is paired with `danger-full-access`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding. - -When you are running with `approval_policy == on-request`, and sandboxing enabled, here are scenarios where you'll need to request approval: -- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /var) -- You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files. -- You are running sandboxed and need to run a command that requires network access (e.g. installing packages) -- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `sandbox_permissions` and `justification` parameters - do not message the user before requesting approval for the command. -- You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for -- (for all of these, you should weigh alternative paths that do not require approval) - -When `sandbox_mode` is set to read-only, you'll need to request approval for any command that isn't a read. - -You will be told what filesystem sandboxing, network sandboxing, and approval mode are active in a developer or user message. If you are not told about this, assume that you are running with workspace-write, network sandboxing enabled, and approval on-failure. - -Although they introduce friction to the user because your work is paused until the user responds, you should leverage them when necessary to accomplish important work. If the completing the task requires escalated permissions, Do not let these settings or the sandbox deter you from attempting to accomplish the user's task unless it is set to "never", in which case never ask for approvals. - -When requesting approval to execute a command that will require escalated privileges: - - Provide the `sandbox_permissions` parameter with the value `"require_escalated"` - - Include a short, 1 sentence explanation for why you need escalated permissions in the justification parameter - ## Special user requests - If the user makes a simple request (such as asking for the time) which you can fulfill by running a terminal command (such as `date`), you should do so. diff --git a/codex-rs/core/prompt.md b/codex-rs/core/prompt.md index d8bebc371b..4886c7ef44 100644 --- a/codex-rs/core/prompt.md +++ b/codex-rs/core/prompt.md @@ -146,41 +146,6 @@ If completing the user's task requires writing or modifying files, your code and - Do not use one-letter variable names unless explicitly requested. - NEVER output inline citations like "【F:README.md†L5-L14】" in your outputs. The CLI is not able to render these so they will just be broken in the UI. Instead, if you output valid filepaths, users will be able to click on them to open the files in their editor. -## Sandbox and approvals - -The Codex CLI harness supports several different sandboxing, and approval configurations that the user can choose from. - -Filesystem sandboxing prevents you from editing files without user approval. The options are: - -- **read-only**: You can only read files. -- **workspace-write**: You can read files. You can write to files in your workspace folder, but not outside it. -- **danger-full-access**: No filesystem sandboxing. - -Network sandboxing prevents you from accessing network without approval. Options are - -- **restricted** -- **enabled** - -Approvals are your mechanism to get user consent to perform more privileged actions. Although they introduce friction to the user because your work is paused until the user responds, you should leverage them to accomplish your important work. Do not let these settings or the sandbox deter you from attempting to accomplish the user's task. Approval options are - -- **untrusted**: The harness will escalate most commands for user approval, apart from a limited allowlist of safe "read" commands. -- **on-failure**: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox. -- **on-request**: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. (Note that this mode is not always available. If it is, you'll see parameters for it in the `shell` command description.) -- **never**: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is pared with `danger-full-access`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding. - -When you are running with approvals `on-request`, and sandboxing enabled, here are scenarios where you'll need to request approval: - -- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /tmp) -- You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files. -- You are running sandboxed and need to run a command that requires network access (e.g. installing packages) -- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. -- You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for -- (For all of these, you should weigh alternative paths that do not require approval.) - -Note that when sandboxing is set to read-only, you'll need to request approval for any command that isn't a read. - -You will be told what filesystem sandboxing, network sandboxing, and approval mode are active in a developer or user message. If you are not told about this, assume that you are running with workspace-write, network sandboxing ON, and approval on-failure. - ## Validating your work If the codebase has tests or the ability to build or run, consider using them to verify that your work is complete. diff --git a/codex-rs/core/prompt_with_apply_patch_instructions.md b/codex-rs/core/prompt_with_apply_patch_instructions.md index af5537c924..f9c308fbd1 100644 --- a/codex-rs/core/prompt_with_apply_patch_instructions.md +++ b/codex-rs/core/prompt_with_apply_patch_instructions.md @@ -146,41 +146,6 @@ If completing the user's task requires writing or modifying files, your code and - Do not use one-letter variable names unless explicitly requested. - NEVER output inline citations like "【F:README.md†L5-L14】" in your outputs. The CLI is not able to render these so they will just be broken in the UI. Instead, if you output valid filepaths, users will be able to click on them to open the files in their editor. -## Sandbox and approvals - -The Codex CLI harness supports several different sandboxing, and approval configurations that the user can choose from. - -Filesystem sandboxing prevents you from editing files without user approval. The options are: - -- **read-only**: You can only read files. -- **workspace-write**: You can read files. You can write to files in your workspace folder, but not outside it. -- **danger-full-access**: No filesystem sandboxing. - -Network sandboxing prevents you from accessing network without approval. Options are - -- **restricted** -- **enabled** - -Approvals are your mechanism to get user consent to perform more privileged actions. Although they introduce friction to the user because your work is paused until the user responds, you should leverage them to accomplish your important work. Do not let these settings or the sandbox deter you from attempting to accomplish the user's task. Approval options are - -- **untrusted**: The harness will escalate most commands for user approval, apart from a limited allowlist of safe "read" commands. -- **on-failure**: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox. -- **on-request**: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. (Note that this mode is not always available. If it is, you'll see parameters for it in the `shell` command description.) -- **never**: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is pared with `danger-full-access`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding. - -When you are running with approvals `on-request`, and sandboxing enabled, here are scenarios where you'll need to request approval: - -- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /tmp) -- You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files. -- You are running sandboxed and need to run a command that requires network access (e.g. installing packages) -- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. -- You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for -- (For all of these, you should weigh alternative paths that do not require approval.) - -Note that when sandboxing is set to read-only, you'll need to request approval for any command that isn't a read. - -You will be told what filesystem sandboxing, network sandboxing, and approval mode are active in a developer or user message. If you are not told about this, assume that you are running with workspace-write, network sandboxing ON, and approval on-failure. - ## Validating your work If the codebase has tests or the ability to build or run, consider using them to verify that your work is complete. diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index ce7e892731..11e8a1745c 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -57,7 +57,7 @@ impl AgentControl { prompt: String, ) -> CodexResult { let state = self.upgrade()?; - state + let result = state .send_op( agent_id, Op::UserInput { @@ -65,13 +65,19 @@ impl AgentControl { final_output_json_schema: None, }, ) - .await + .await; + if matches!(result, Err(CodexErr::InternalAgentDied)) { + let _ = state.remove_thread(&agent_id).await; + } + result } /// Submit a shutdown request to an existing agent thread. pub(crate) async fn shutdown_agent(&self, agent_id: ThreadId) -> CodexResult { let state = self.upgrade()?; - state.send_op(agent_id, Op::Shutdown {}).await + let result = state.send_op(agent_id, Op::Shutdown {}).await; + let _ = state.remove_thread(&agent_id).await; + result } #[allow(dead_code)] // Will be used for collab tools. diff --git a/codex-rs/core/src/auth/storage.rs b/codex-rs/core/src/auth/storage.rs index a238eb9c38..48b67aca05 100644 --- a/codex-rs/core/src/auth/storage.rs +++ b/codex-rs/core/src/auth/storage.rs @@ -1,5 +1,6 @@ use chrono::DateTime; use chrono::Utc; +use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use sha2::Digest; @@ -21,7 +22,7 @@ use codex_keyring_store::DefaultKeyringStore; use codex_keyring_store::KeyringStore; /// Determine where Codex should store CLI auth credentials. -#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "lowercase")] pub enum AuthCredentialsStoreMode { #[default] diff --git a/codex-rs/core/src/bin/config_schema.rs b/codex-rs/core/src/bin/config_schema.rs new file mode 100644 index 0000000000..8d33df42e1 --- /dev/null +++ b/codex-rs/core/src/bin/config_schema.rs @@ -0,0 +1,20 @@ +use anyhow::Result; +use clap::Parser; +use std::path::PathBuf; + +/// Generate the JSON Schema for `config.toml` and write it to `config.schema.json`. +#[derive(Parser)] +#[command(name = "codex-write-config-schema")] +struct Args { + #[arg(short, long, value_name = "PATH")] + out: Option, +} + +fn main() -> Result<()> { + let args = Args::parse(); + let out_path = args + .out + .unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("config.schema.json")); + codex_core::config::schema::write_config_schema(&out_path)?; + Ok(()) +} diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index bec015b4c5..438c7c5eee 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use crate::api_bridge::CoreAuthProvider; use crate::api_bridge::auth_provider_from_auth; use crate::api_bridge::map_api_error; use crate::auth::UnauthorizedRecovery; @@ -10,12 +11,18 @@ use codex_api::CompactionInput as ApiCompactionInput; use codex_api::Prompt as ApiPrompt; use codex_api::RequestTelemetry; use codex_api::ReqwestTransport; +use codex_api::ResponseAppendWsRequest; +use codex_api::ResponseCreateWsRequest; use codex_api::ResponseStream as ApiResponseStream; use codex_api::ResponsesClient as ApiResponsesClient; use codex_api::ResponsesOptions as ApiResponsesOptions; +use codex_api::ResponsesWebsocketClient as ApiWebSocketResponsesClient; +use codex_api::ResponsesWebsocketConnection as ApiWebSocketConnection; use codex_api::SseTelemetry; use codex_api::TransportError; +use codex_api::build_conversation_headers; use codex_api::common::Reasoning; +use codex_api::common::ResponsesWsRequest; use codex_api::create_text_param_for_request; use codex_api::error::ApiError; use codex_api::requests::responses::Compression; @@ -57,8 +64,8 @@ use crate::model_provider_info::WireApi; use crate::tools::spec::create_tools_json_for_chat_completions_api; use crate::tools::spec::create_tools_json_for_responses_api; -#[derive(Debug, Clone)] -pub struct ModelClient { +#[derive(Debug)] +struct ModelClientState { config: Arc, auth_manager: Option>, model_info: ModelInfo, @@ -70,6 +77,17 @@ pub struct ModelClient { session_source: SessionSource, } +#[derive(Debug, Clone)] +pub struct ModelClient { + state: Arc, +} + +pub struct ModelClientSession { + state: Arc, + connection: Option, + websocket_last_items: Vec, +} + #[allow(clippy::too_many_arguments)] impl ModelClient { pub fn new( @@ -84,20 +102,32 @@ impl ModelClient { session_source: SessionSource, ) -> Self { Self { - config, - auth_manager, - model_info, - otel_manager, - provider, - conversation_id, - effort, - summary, - session_source, + state: Arc::new(ModelClientState { + config, + auth_manager, + model_info, + otel_manager, + provider, + conversation_id, + effort, + summary, + session_source, + }), } } + pub fn new_session(&self) -> ModelClientSession { + ModelClientSession { + state: Arc::clone(&self.state), + connection: None, + websocket_last_items: Vec::new(), + } + } +} + +impl ModelClient { pub fn get_model_context_window(&self) -> Option { - let model_info = self.get_model_info(); + let model_info = &self.state.model_info; let effective_context_window_percent = model_info.effective_context_window_percent; model_info.context_window.map(|context_window| { context_window.saturating_mul(effective_context_window_percent) / 100 @@ -105,39 +135,290 @@ impl ModelClient { } pub fn config(&self) -> Arc { - Arc::clone(&self.config) + Arc::clone(&self.state.config) } pub fn provider(&self) -> &ModelProviderInfo { - &self.provider + &self.state.provider } + pub fn get_provider(&self) -> ModelProviderInfo { + self.state.provider.clone() + } + + pub fn get_otel_manager(&self) -> OtelManager { + self.state.otel_manager.clone() + } + + pub fn get_session_source(&self) -> SessionSource { + self.state.session_source.clone() + } + + /// Returns the currently configured model slug. + pub fn get_model(&self) -> String { + self.state.model_info.slug.clone() + } + + pub fn get_model_info(&self) -> ModelInfo { + self.state.model_info.clone() + } + + /// Returns the current reasoning effort setting. + pub fn get_reasoning_effort(&self) -> Option { + self.state.effort + } + + /// Returns the current reasoning summary setting. + pub fn get_reasoning_summary(&self) -> ReasoningSummaryConfig { + self.state.summary + } + + pub fn get_auth_manager(&self) -> Option> { + self.state.auth_manager.clone() + } + + /// Compacts the current conversation history using the Compact endpoint. + /// + /// This is a unary call (no streaming) that returns a new list of + /// `ResponseItem`s representing the compacted transcript. + pub async fn compact_conversation_history(&self, prompt: &Prompt) -> Result> { + if prompt.input.is_empty() { + return Ok(Vec::new()); + } + let auth_manager = self.state.auth_manager.clone(); + let auth = match auth_manager.as_ref() { + Some(manager) => manager.auth().await, + None => None, + }; + let api_provider = self + .state + .provider + .to_api_provider(auth.as_ref().map(|a| a.mode))?; + let api_auth = auth_provider_from_auth(auth.clone(), &self.state.provider)?; + let transport = ReqwestTransport::new(build_reqwest_client()); + let request_telemetry = self.build_request_telemetry(); + let client = ApiCompactClient::new(transport, api_provider, api_auth) + .with_telemetry(Some(request_telemetry)); + + let instructions = prompt + .get_full_instructions(&self.state.model_info) + .into_owned(); + let payload = ApiCompactionInput { + model: &self.state.model_info.slug, + input: &prompt.input, + instructions: &instructions, + }; + + let mut extra_headers = ApiHeaderMap::new(); + if let SessionSource::SubAgent(sub) = &self.state.session_source { + let subagent = if let crate::protocol::SubAgentSource::Other(label) = sub { + label.clone() + } else { + serde_json::to_value(sub) + .ok() + .and_then(|v| v.as_str().map(std::string::ToString::to_string)) + .unwrap_or_else(|| "other".to_string()) + }; + if let Ok(val) = HeaderValue::from_str(&subagent) { + extra_headers.insert("x-openai-subagent", val); + } + } + + client + .compact_input(&payload, extra_headers) + .await + .map_err(map_api_error) + } +} + +impl ModelClientSession { /// Streams a single model turn using either the Responses or Chat /// Completions wire API, depending on the configured provider. /// /// For Chat providers, the underlying stream is optionally aggregated /// based on the `show_raw_agent_reasoning` flag in the config. - pub async fn stream(&self, prompt: &Prompt) -> Result { - match self.provider.wire_api { + pub async fn stream(&mut self, prompt: &Prompt) -> Result { + match self.state.provider.wire_api { WireApi::Responses => self.stream_responses_api(prompt).await, + WireApi::ResponsesWebsocket => self.stream_responses_websocket(prompt).await, WireApi::Chat => { let api_stream = self.stream_chat_completions(prompt).await?; - if self.config.show_raw_agent_reasoning { + if self.state.config.show_raw_agent_reasoning { Ok(map_response_stream( api_stream.streaming_mode(), - self.otel_manager.clone(), + self.state.otel_manager.clone(), )) } else { Ok(map_response_stream( api_stream.aggregate(), - self.otel_manager.clone(), + self.state.otel_manager.clone(), )) } } } } + fn build_responses_request(&self, prompt: &Prompt) -> Result { + let model_info = self.state.model_info.clone(); + let instructions = prompt.get_full_instructions(&model_info).into_owned(); + let tools_json: Vec = create_tools_json_for_responses_api(&prompt.tools)?; + Ok(build_api_prompt(prompt, instructions, tools_json)) + } + + fn build_responses_options( + &self, + prompt: &Prompt, + compression: Compression, + ) -> ApiResponsesOptions { + let model_info = &self.state.model_info; + + let default_reasoning_effort = model_info.default_reasoning_level; + let reasoning = if model_info.supports_reasoning_summaries { + Some(Reasoning { + effort: self.state.effort.or(default_reasoning_effort), + summary: if self.state.summary == ReasoningSummaryConfig::None { + None + } else { + Some(self.state.summary) + }, + }) + } else { + None + }; + + let include = if reasoning.is_some() { + vec!["reasoning.encrypted_content".to_string()] + } else { + Vec::new() + }; + + let verbosity = if model_info.support_verbosity { + self.state + .config + .model_verbosity + .or(model_info.default_verbosity) + } else { + if self.state.config.model_verbosity.is_some() { + warn!( + "model_verbosity is set but ignored as the model does not support verbosity: {}", + model_info.slug + ); + } + None + }; + + let text = create_text_param_for_request(verbosity, &prompt.output_schema); + let conversation_id = self.state.conversation_id.to_string(); + + ApiResponsesOptions { + reasoning, + include, + prompt_cache_key: Some(conversation_id.clone()), + text, + store_override: None, + conversation_id: Some(conversation_id), + session_source: Some(self.state.session_source.clone()), + extra_headers: beta_feature_headers(&self.state.config), + compression, + } + } + + fn get_incremental_items(&self, input_items: &[ResponseItem]) -> Option> { + // Checks whether the current request input is an incremental append to the previous request. + // If items in the new request contain all the items from the previous request we build + // a response.append request otherwise we start with a fresh response.create request. + let previous_len = self.websocket_last_items.len(); + let can_append = previous_len > 0 + && input_items.starts_with(&self.websocket_last_items) + && previous_len < input_items.len(); + if can_append { + Some(input_items[previous_len..].to_vec()) + } else { + None + } + } + + fn prepare_websocket_request( + &self, + api_prompt: &ApiPrompt, + options: &ApiResponsesOptions, + ) -> ResponsesWsRequest { + if let Some(append_items) = self.get_incremental_items(&api_prompt.input) { + return ResponsesWsRequest::ResponseAppend(ResponseAppendWsRequest { + input: append_items, + }); + } + + let ApiResponsesOptions { + reasoning, + include, + prompt_cache_key, + text, + store_override, + .. + } = options; + + let store = store_override.unwrap_or(false); + let payload = ResponseCreateWsRequest { + model: self.state.model_info.slug.clone(), + instructions: api_prompt.instructions.clone(), + input: api_prompt.input.clone(), + tools: api_prompt.tools.clone(), + tool_choice: "auto".to_string(), + parallel_tool_calls: api_prompt.parallel_tool_calls, + reasoning: reasoning.clone(), + store, + stream: true, + include: include.clone(), + prompt_cache_key: prompt_cache_key.clone(), + text: text.clone(), + }; + + ResponsesWsRequest::ResponseCreate(payload) + } + + async fn websocket_connection( + &mut self, + api_provider: codex_api::Provider, + api_auth: CoreAuthProvider, + options: &ApiResponsesOptions, + ) -> std::result::Result<&ApiWebSocketConnection, ApiError> { + let needs_new = match self.connection.as_ref() { + Some(conn) => conn.is_closed().await, + None => true, + }; + + if needs_new { + let mut headers = options.extra_headers.clone(); + headers.extend(build_conversation_headers(options.conversation_id.clone())); + let new_conn: ApiWebSocketConnection = + ApiWebSocketResponsesClient::new(api_provider, api_auth) + .connect(headers) + .await?; + self.connection = Some(new_conn); + } + + self.connection.as_ref().ok_or(ApiError::Stream( + "websocket connection is unavailable".to_string(), + )) + } + + fn responses_request_compression(&self, auth: Option<&crate::auth::CodexAuth>) -> Compression { + if self + .state + .config + .features + .enabled(Feature::EnableRequestCompression) + && auth.is_some_and(|auth| auth.mode == AuthMode::ChatGPT) + && self.state.provider.is_openai() + { + Compression::Zstd + } else { + Compression::None + } + } + /// Streams a turn via the OpenAI Chat Completions API. /// /// This path is only used when the provider is configured with @@ -149,13 +430,13 @@ impl ModelClient { )); } - let auth_manager = self.auth_manager.clone(); - let model_info = self.get_model_info(); + let auth_manager = self.state.auth_manager.clone(); + let model_info = self.state.model_info.clone(); let instructions = prompt.get_full_instructions(&model_info).into_owned(); let tools_json = create_tools_json_for_chat_completions_api(&prompt.tools)?; let api_prompt = build_api_prompt(prompt, instructions, tools_json); - let conversation_id = self.conversation_id.to_string(); - let session_source = self.session_source.clone(); + let conversation_id = self.state.conversation_id.to_string(); + let session_source = self.state.session_source.clone(); let mut auth_recovery = auth_manager .as_ref() @@ -166,9 +447,10 @@ impl ModelClient { None => None, }; let api_provider = self + .state .provider .to_api_provider(auth.as_ref().map(|a| a.mode))?; - let api_auth = auth_provider_from_auth(auth.clone(), &self.provider)?; + let api_auth = auth_provider_from_auth(auth.clone(), &self.state.provider)?; let transport = ReqwestTransport::new(build_reqwest_client()); let (request_telemetry, sse_telemetry) = self.build_streaming_telemetry(); let client = ApiChatClient::new(transport, api_provider, api_auth) @@ -176,7 +458,7 @@ impl ModelClient { let stream_result = client .stream_prompt( - &self.get_model(), + &self.state.model_info.slug, &api_prompt, Some(conversation_id.clone()), Some(session_source.clone()), @@ -203,52 +485,14 @@ impl ModelClient { async fn stream_responses_api(&self, prompt: &Prompt) -> Result { if let Some(path) = &*CODEX_RS_SSE_FIXTURE { warn!(path, "Streaming from fixture"); - let stream = codex_api::stream_from_fixture(path, self.provider.stream_idle_timeout()) - .map_err(map_api_error)?; - return Ok(map_response_stream(stream, self.otel_manager.clone())); + let stream = + codex_api::stream_from_fixture(path, self.state.provider.stream_idle_timeout()) + .map_err(map_api_error)?; + return Ok(map_response_stream(stream, self.state.otel_manager.clone())); } - let auth_manager = self.auth_manager.clone(); - let model_info = self.get_model_info(); - let instructions = prompt.get_full_instructions(&model_info).into_owned(); - let tools_json: Vec = create_tools_json_for_responses_api(&prompt.tools)?; - - let default_reasoning_effort = model_info.default_reasoning_level; - let reasoning = if model_info.supports_reasoning_summaries { - Some(Reasoning { - effort: self.effort.or(default_reasoning_effort), - summary: if self.summary == ReasoningSummaryConfig::None { - None - } else { - Some(self.summary) - }, - }) - } else { - None - }; - - let include: Vec = if reasoning.is_some() { - vec!["reasoning.encrypted_content".to_string()] - } else { - vec![] - }; - - let verbosity = if model_info.support_verbosity { - self.config.model_verbosity.or(model_info.default_verbosity) - } else { - if self.config.model_verbosity.is_some() { - warn!( - "model_verbosity is set but ignored as the model does not support verbosity: {}", - model_info.slug - ); - } - None - }; - - let text = create_text_param_for_request(verbosity, &prompt.output_schema); - let api_prompt = build_api_prompt(prompt, instructions.clone(), tools_json); - let conversation_id = self.conversation_id.to_string(); - let session_source = self.session_source.clone(); + let auth_manager = self.state.auth_manager.clone(); + let api_prompt = self.build_responses_request(prompt)?; let mut auth_recovery = auth_manager .as_ref() @@ -259,47 +503,26 @@ impl ModelClient { None => None, }; let api_provider = self + .state .provider .to_api_provider(auth.as_ref().map(|a| a.mode))?; - let api_auth = auth_provider_from_auth(auth.clone(), &self.provider)?; + let api_auth = auth_provider_from_auth(auth.clone(), &self.state.provider)?; let transport = ReqwestTransport::new(build_reqwest_client()); let (request_telemetry, sse_telemetry) = self.build_streaming_telemetry(); - let compression = if self - .config - .features - .enabled(Feature::EnableRequestCompression) - && auth - .as_ref() - .is_some_and(|auth| auth.mode == AuthMode::ChatGPT) - && self.provider.is_openai() - { - Compression::Zstd - } else { - Compression::None - }; + let compression = self.responses_request_compression(auth.as_ref()); let client = ApiResponsesClient::new(transport, api_provider, api_auth) .with_telemetry(Some(request_telemetry), Some(sse_telemetry)); - let options = ApiResponsesOptions { - reasoning: reasoning.clone(), - include: include.clone(), - prompt_cache_key: Some(conversation_id.clone()), - text: text.clone(), - store_override: None, - conversation_id: Some(conversation_id.clone()), - session_source: Some(session_source.clone()), - extra_headers: beta_feature_headers(&self.config), - compression, - }; + let options = self.build_responses_options(prompt, compression); let stream_result = client - .stream_prompt(&self.get_model(), &api_prompt, options) + .stream_prompt(&self.state.model_info.slug, &api_prompt, options) .await; match stream_result { Ok(stream) => { - return Ok(map_response_stream(stream, self.otel_manager.clone())); + return Ok(map_response_stream(stream, self.state.otel_manager.clone())); } Err(ApiError::Transport(TransportError::Http { status, .. })) if status == StatusCode::UNAUTHORIZED => @@ -312,106 +535,69 @@ impl ModelClient { } } - pub fn get_provider(&self) -> ModelProviderInfo { - self.provider.clone() - } + /// Streams a turn via the Responses API over WebSocket transport. + async fn stream_responses_websocket(&mut self, prompt: &Prompt) -> Result { + let auth_manager = self.state.auth_manager.clone(); + let api_prompt = self.build_responses_request(prompt)?; - pub fn get_otel_manager(&self) -> OtelManager { - self.otel_manager.clone() - } - - pub fn get_session_source(&self) -> SessionSource { - self.session_source.clone() - } - - /// Returns the currently configured model slug. - pub fn get_model(&self) -> String { - self.model_info.slug.clone() - } - - pub fn get_model_info(&self) -> ModelInfo { - self.model_info.clone() - } - - /// Returns the current reasoning effort setting. - pub fn get_reasoning_effort(&self) -> Option { - self.effort - } - - /// Returns the current reasoning summary setting. - pub fn get_reasoning_summary(&self) -> ReasoningSummaryConfig { - self.summary - } - - pub fn get_auth_manager(&self) -> Option> { - self.auth_manager.clone() - } - - /// Compacts the current conversation history using the Compact endpoint. - /// - /// This is a unary call (no streaming) that returns a new list of - /// `ResponseItem`s representing the compacted transcript. - pub async fn compact_conversation_history(&self, prompt: &Prompt) -> Result> { - if prompt.input.is_empty() { - return Ok(Vec::new()); - } - let auth_manager = self.auth_manager.clone(); - let auth = match auth_manager.as_ref() { - Some(manager) => manager.auth().await, - None => None, - }; - let api_provider = self - .provider - .to_api_provider(auth.as_ref().map(|a| a.mode))?; - let api_auth = auth_provider_from_auth(auth.clone(), &self.provider)?; - let transport = ReqwestTransport::new(build_reqwest_client()); - let request_telemetry = self.build_request_telemetry(); - let client = ApiCompactClient::new(transport, api_provider, api_auth) - .with_telemetry(Some(request_telemetry)); - - let instructions = prompt - .get_full_instructions(&self.get_model_info()) - .into_owned(); - let payload = ApiCompactionInput { - model: &self.get_model(), - input: &prompt.input, - instructions: &instructions, - }; - - let mut extra_headers = ApiHeaderMap::new(); - if let SessionSource::SubAgent(sub) = &self.session_source { - let subagent = if let crate::protocol::SubAgentSource::Other(label) = sub { - label.clone() - } else { - serde_json::to_value(sub) - .ok() - .and_then(|v| v.as_str().map(std::string::ToString::to_string)) - .unwrap_or_else(|| "other".to_string()) + let mut auth_recovery = auth_manager + .as_ref() + .map(super::auth::AuthManager::unauthorized_recovery); + loop { + let auth = match auth_manager.as_ref() { + Some(manager) => manager.auth().await, + None => None, }; - if let Ok(val) = HeaderValue::from_str(&subagent) { - extra_headers.insert("x-openai-subagent", val); - } + let api_provider = self + .state + .provider + .to_api_provider(auth.as_ref().map(|a| a.mode))?; + let api_auth = auth_provider_from_auth(auth.clone(), &self.state.provider)?; + let compression = self.responses_request_compression(auth.as_ref()); + + let options = self.build_responses_options(prompt, compression); + let request = self.prepare_websocket_request(&api_prompt, &options); + + let connection = match self + .websocket_connection(api_provider.clone(), api_auth.clone(), &options) + .await + { + Ok(connection) => connection, + Err(ApiError::Transport(TransportError::Http { status, .. })) + if status == StatusCode::UNAUTHORIZED => + { + handle_unauthorized(status, &mut auth_recovery).await?; + continue; + } + Err(err) => return Err(map_api_error(err)), + }; + + let stream_result = connection + .stream_request(request) + .await + .map_err(map_api_error)?; + self.websocket_last_items = api_prompt.input.clone(); + + return Ok(map_response_stream( + stream_result, + self.state.otel_manager.clone(), + )); } - - client - .compact_input(&payload, extra_headers) - .await - .map_err(map_api_error) } -} -impl ModelClient { /// Builds request and SSE telemetry for streaming API calls (Chat/Responses). fn build_streaming_telemetry(&self) -> (Arc, Arc) { - let telemetry = Arc::new(ApiTelemetry::new(self.otel_manager.clone())); + let telemetry = Arc::new(ApiTelemetry::new(self.state.otel_manager.clone())); let request_telemetry: Arc = telemetry.clone(); let sse_telemetry: Arc = telemetry; (request_telemetry, sse_telemetry) } +} +impl ModelClient { /// Builds request telemetry for unary API calls (e.g., Compact endpoint). fn build_request_telemetry(&self) -> Arc { - let telemetry = Arc::new(ApiTelemetry::new(self.otel_manager.clone())); + let telemetry = Arc::new(ApiTelemetry::new(self.state.otel_manager.clone())); let request_telemetry: Arc = telemetry; request_telemetry } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 7f26acf176..d913e1d3cf 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -48,6 +48,7 @@ use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnStartedEvent; use codex_rmcp_client::ElicitationResponse; +use codex_rmcp_client::OAuthCredentialsStoreMode; use futures::future::BoxFuture; use futures::prelude::*; use futures::stream::FuturesOrdered; @@ -77,6 +78,7 @@ use tracing::warn; use crate::ModelProviderInfo; use crate::WireApi; use crate::client::ModelClient; +use crate::client::ModelClientSession; use crate::client_common::Prompt; use crate::client_common::ResponseEvent; use crate::compact::collect_user_messages; @@ -84,6 +86,7 @@ use crate::config::Config; use crate::config::Constrained; use crate::config::ConstraintResult; use crate::config::GhostSnapshotConfig; +use crate::config::types::McpServerConfig; use crate::config::types::ShellEnvironmentPolicy; use crate::context_manager::ContextManager; use crate::environment_context::EnvironmentContext; @@ -107,6 +110,7 @@ use crate::protocol::ErrorEvent; use crate::protocol::Event; use crate::protocol::EventMsg; use crate::protocol::ExecApprovalRequestEvent; +use crate::protocol::McpServerRefreshConfig; use crate::protocol::Op; use crate::protocol::RateLimitSnapshot; use crate::protocol::ReasoningContentDeltaEvent; @@ -148,7 +152,6 @@ use crate::tools::spec::ToolsConfig; use crate::tools::spec::ToolsConfigParams; use crate::turn_diff_tracker::TurnDiffTracker; use crate::unified_exec::UnifiedExecProcessManager; -use crate::user_instructions::DeveloperInstructions; use crate::user_instructions::UserInstructions; use crate::user_notification::UserNotification; use crate::util::backoff; @@ -156,6 +159,7 @@ use codex_async_utils::OrCancelExt; use codex_otel::OtelManager; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::models::ContentItem; +use codex_protocol::models::DeveloperInstructions; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; @@ -361,6 +365,7 @@ pub(crate) struct Session { /// The set of enabled features should be invariant for the lifetime of the /// session. features: Features, + pending_mcp_server_refresh_config: Mutex>, pub(crate) active_turn: Mutex>, pub(crate) services: SessionServices, next_internal_sub_id: AtomicU64, @@ -685,7 +690,7 @@ impl Session { let services = SessionServices { mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::default())), - mcp_startup_cancellation_token: CancellationToken::new(), + mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()), unified_exec_manager: UnifiedExecProcessManager::default(), notifier: UserNotifier::new(config.notify.clone()), rollout: Mutex::new(Some(rollout_recorder)), @@ -706,6 +711,7 @@ impl Session { agent_status, state: Mutex::new(state), features: config.features.clone(), + pending_mcp_server_refresh_config: Mutex::new(None), active_turn: Mutex::new(None), services, next_internal_sub_id: AtomicU64::new(0), @@ -742,16 +748,18 @@ impl Session { codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(), sandbox_cwd: session_configuration.cwd.clone(), }; + let cancel_token = sess.mcp_startup_cancellation_token().await; + sess.services .mcp_connection_manager .write() .await .initialize( - config.mcp_servers.clone(), + &config.mcp_servers, config.mcp_oauth_credentials_store_mode, auth_statuses.clone(), tx_event.clone(), - sess.services.mcp_startup_cancellation_token.clone(), + cancel_token, sandbox_state, ) .await; @@ -852,6 +860,11 @@ impl Session { if persist && !rollout_items.is_empty() { self.persist_rollout_items(&rollout_items).await; } + + // Append the current session's initial context after the reconstructed history. + let initial_context = self.build_initial_context(&turn_context); + self.record_conversation_items(&turn_context, &initial_context) + .await; // Flush after seeding history and any persisted rollout copy. self.flush_rollout().await; } @@ -1004,6 +1017,28 @@ impl Session { ))) } + fn build_permissions_update_item( + &self, + previous: Option<&Arc>, + next: &TurnContext, + ) -> Option { + let prev = previous?; + if prev.sandbox_policy == next.sandbox_policy + && prev.approval_policy == next.approval_policy + { + return None; + } + + Some( + DeveloperInstructions::from_policy( + &next.sandbox_policy, + next.approval_policy, + &next.cwd, + ) + .into(), + ) + } + /// Persist the event to rollout and send it to clients. pub(crate) async fn send_event(&self, turn_context: &TurnContext, msg: EventMsg) { let legacy_source = msg.clone(); @@ -1333,8 +1368,16 @@ impl Session { } pub(crate) fn build_initial_context(&self, turn_context: &TurnContext) -> Vec { - let mut items = Vec::::with_capacity(3); + let mut items = Vec::::with_capacity(4); let shell = self.user_shell(); + items.push( + DeveloperInstructions::from_policy( + &turn_context.sandbox_policy, + turn_context.approval_policy, + &turn_context.cwd, + ) + .into(), + ); if let Some(developer_instructions) = turn_context.developer_instructions.as_deref() { items.push(DeveloperInstructions::new(developer_instructions.to_string()).into()); } @@ -1349,8 +1392,6 @@ impl Session { } items.push(ResponseItem::from(EnvironmentContext::new( Some(turn_context.cwd.clone()), - Some(turn_context.approval_policy), - Some(turn_context.sandbox_policy.clone()), shell.as_ref().clone(), ))); items @@ -1567,6 +1608,17 @@ impl Session { } } + pub async fn has_pending_input(&self) -> bool { + let active = self.active_turn.lock().await; + match active.as_ref() { + Some(at) => { + let ts = at.turn_state.lock().await; + ts.has_pending_input() + } + None => false, + } + } + pub async fn list_resources( &self, server: &str, @@ -1647,12 +1699,85 @@ impl Session { Arc::clone(&self.services.user_shell) } + async fn refresh_mcp_servers_if_requested(&self, turn_context: &TurnContext) { + let refresh_config = { self.pending_mcp_server_refresh_config.lock().await.take() }; + let Some(refresh_config) = refresh_config else { + return; + }; + + let McpServerRefreshConfig { + mcp_servers, + mcp_oauth_credentials_store_mode, + } = refresh_config; + + let mcp_servers = + match serde_json::from_value::>(mcp_servers) { + Ok(servers) => servers, + Err(err) => { + warn!("failed to parse MCP server refresh config: {err}"); + return; + } + }; + let store_mode = match serde_json::from_value::( + mcp_oauth_credentials_store_mode, + ) { + Ok(mode) => mode, + Err(err) => { + warn!("failed to parse MCP OAuth refresh config: {err}"); + return; + } + }; + + let auth_statuses = compute_auth_statuses(mcp_servers.iter(), store_mode).await; + let sandbox_state = SandboxState { + sandbox_policy: turn_context.sandbox_policy.clone(), + codex_linux_sandbox_exe: turn_context.codex_linux_sandbox_exe.clone(), + sandbox_cwd: turn_context.cwd.clone(), + }; + let cancel_token = self.reset_mcp_startup_cancellation_token().await; + + let mut refreshed_manager = McpConnectionManager::default(); + refreshed_manager + .initialize( + &mcp_servers, + store_mode, + auth_statuses, + self.get_tx_event(), + cancel_token, + sandbox_state, + ) + .await; + + let mut manager = self.services.mcp_connection_manager.write().await; + *manager = refreshed_manager; + } + + async fn mcp_startup_cancellation_token(&self) -> CancellationToken { + self.services + .mcp_startup_cancellation_token + .lock() + .await + .clone() + } + + async fn reset_mcp_startup_cancellation_token(&self) -> CancellationToken { + let mut guard = self.services.mcp_startup_cancellation_token.lock().await; + guard.cancel(); + let cancel_token = CancellationToken::new(); + *guard = cancel_token.clone(); + cancel_token + } + fn show_raw_agent_reasoning(&self) -> bool { self.services.show_raw_agent_reasoning } async fn cancel_mcp_startup(&self) { - self.services.mcp_startup_cancellation_token.cancel(); + self.services + .mcp_startup_cancellation_token + .lock() + .await + .cancel(); } } @@ -1710,6 +1835,9 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv Op::ListMcpTools => { handlers::list_mcp_tools(&sess, &config, sub.id.clone()).await; } + Op::RefreshMcpServers { config } => { + handlers::refresh_mcp_servers(&sess, config).await; + } Op::ListCustomPrompts => { handlers::list_custom_prompts(&sess, sub.id.clone()).await; } @@ -1778,6 +1906,7 @@ mod handlers { use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ListCustomPromptsResponseEvent; use codex_protocol::protocol::ListSkillsResponseEvent; + use codex_protocol::protocol::McpServerRefreshConfig; use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; @@ -1869,13 +1998,24 @@ mod handlers { // Attempt to inject input into current task if let Err(items) = sess.inject_input(items).await { + let mut update_items = Vec::new(); if let Some(env_item) = sess.build_environment_update_item(previous_context.as_ref(), ¤t_context) { - sess.record_conversation_items(¤t_context, std::slice::from_ref(&env_item)) + update_items.push(env_item); + } + if let Some(permissions_item) = + sess.build_permissions_update_item(previous_context.as_ref(), ¤t_context) + { + update_items.push(permissions_item); + } + if !update_items.is_empty() { + sess.record_conversation_items(¤t_context, &update_items) .await; } + sess.refresh_mcp_servers_if_requested(¤t_context) + .await; sess.spawn_task(Arc::clone(¤t_context), items, RegularTask) .await; *previous_context = Some(current_context); @@ -2007,6 +2147,11 @@ mod handlers { }); } + pub async fn refresh_mcp_servers(sess: &Arc, refresh_config: McpServerRefreshConfig) { + let mut guard = sess.pending_mcp_server_refresh_config.lock().await; + *guard = Some(refresh_config); + } + pub async fn list_mcp_tools(sess: &Session, config: &Arc, sub_id: String) { let mcp_connection_manager = sess.services.mcp_connection_manager.read().await; let snapshot = collect_mcp_snapshot_from_manager( @@ -2191,6 +2336,7 @@ mod handlers { review_request: ReviewRequest, ) { let turn_context = sess.new_default_turn_with_sub_id(sub_id.clone()).await; + sess.refresh_mcp_servers_if_requested(&turn_context).await; match resolve_review_request(review_request, turn_context.cwd.as_path()) { Ok(resolved) => { spawn_review_thread( @@ -2397,6 +2543,8 @@ pub(crate) async fn run_turn( // many turns, from the perspective of the user, it is a single turn. let turn_diff_tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); + let mut client_session = turn_context.client.new_session(); + loop { // Note that pending_input would be something like a message the user // submitted through the UI while the model was running. Though the UI @@ -2427,6 +2575,7 @@ pub(crate) async fn run_turn( Arc::clone(&sess), Arc::clone(&turn_context), Arc::clone(&turn_diff_tracker), + &mut client_session, turn_input, cancellation_token.child_token(), ) @@ -2504,6 +2653,7 @@ async fn run_model_turn( sess: Arc, turn_context: Arc, turn_diff_tracker: SharedTurnDiffTracker, + client_session: &mut ModelClientSession, input: Vec, cancellation_token: CancellationToken, ) -> CodexResult { @@ -2544,6 +2694,7 @@ async fn run_model_turn( Arc::clone(&router), Arc::clone(&sess), Arc::clone(&turn_context), + client_session, Arc::clone(&turn_diff_tracker), &prompt, cancellation_token.child_token(), @@ -2635,6 +2786,7 @@ async fn try_run_turn( router: Arc, sess: Arc, turn_context: Arc, + client_session: &mut ModelClientSession, turn_diff_tracker: SharedTurnDiffTracker, prompt: &Prompt, cancellation_token: CancellationToken, @@ -2663,9 +2815,7 @@ async fn try_run_turn( ); sess.persist_rollout_items(&[rollout_item]).await; - let mut stream = turn_context - .client - .clone() + let mut stream = client_session .stream(prompt) .instrument(trace_span!("stream_request")) .or_cancel(&cancellation_token) @@ -2767,6 +2917,9 @@ async fn try_run_turn( .await; should_emit_turn_diff = true; + needs_follow_up |= sess.has_pending_input().await; + error!("needs_follow_up: {needs_follow_up}"); + break Ok(TurnRunResult { needs_follow_up, last_agent_message, @@ -2943,7 +3096,7 @@ mod tests { #[tokio::test] async fn record_initial_history_reconstructs_resumed_transcript() { let (session, turn_context) = make_session_and_context().await; - let (rollout_items, expected) = sample_rollout(&session, &turn_context); + let (rollout_items, mut expected) = sample_rollout(&session, &turn_context); session .record_initial_history(InitialHistory::Resumed(ResumedHistory { @@ -2953,6 +3106,7 @@ mod tests { })) .await; + expected.extend(session.build_initial_context(&turn_context)); let history = session.state.lock().await.clone_history(); assert_eq!(expected, history.raw_items()); } @@ -3037,12 +3191,13 @@ mod tests { #[tokio::test] async fn record_initial_history_reconstructs_forked_transcript() { let (session, turn_context) = make_session_and_context().await; - let (rollout_items, expected) = sample_rollout(&session, &turn_context); + let (rollout_items, mut expected) = sample_rollout(&session, &turn_context); session .record_initial_history(InitialHistory::Forked(rollout_items)) .await; + expected.extend(session.build_initial_context(&turn_context)); let history = session.state.lock().await.clone_history(); assert_eq!(expected, history.raw_items()); } @@ -3526,7 +3681,7 @@ mod tests { let services = SessionServices { mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::default())), - mcp_startup_cancellation_token: CancellationToken::new(), + mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()), unified_exec_manager: UnifiedExecProcessManager::default(), notifier: UserNotifier::new(None), rollout: Mutex::new(None), @@ -3558,6 +3713,7 @@ mod tests { agent_status: agent_status_tx, state: Mutex::new(state), features: config.features.clone(), + pending_mcp_server_refresh_config: Mutex::new(None), active_turn: Mutex::new(None), services, next_internal_sub_id: AtomicU64::new(0), @@ -3620,7 +3776,7 @@ mod tests { let services = SessionServices { mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::default())), - mcp_startup_cancellation_token: CancellationToken::new(), + mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()), unified_exec_manager: UnifiedExecProcessManager::default(), notifier: UserNotifier::new(None), rollout: Mutex::new(None), @@ -3652,6 +3808,7 @@ mod tests { agent_status: agent_status_tx, state: Mutex::new(state), features: config.features.clone(), + pending_mcp_server_refresh_config: Mutex::new(None), active_turn: Mutex::new(None), services, next_internal_sub_id: AtomicU64::new(0), @@ -3660,6 +3817,48 @@ mod tests { (session, turn_context, rx_event) } + #[tokio::test] + async fn refresh_mcp_servers_is_deferred_until_next_turn() { + let (session, turn_context) = make_session_and_context().await; + let old_token = session.mcp_startup_cancellation_token().await; + assert!(!old_token.is_cancelled()); + + let mcp_oauth_credentials_store_mode = + serde_json::to_value(OAuthCredentialsStoreMode::Auto).expect("serialize store mode"); + let refresh_config = McpServerRefreshConfig { + mcp_servers: json!({}), + mcp_oauth_credentials_store_mode, + }; + { + let mut guard = session.pending_mcp_server_refresh_config.lock().await; + *guard = Some(refresh_config); + } + + assert!(!old_token.is_cancelled()); + assert!( + session + .pending_mcp_server_refresh_config + .lock() + .await + .is_some() + ); + + session + .refresh_mcp_servers_if_requested(&turn_context) + .await; + + assert!(old_token.is_cancelled()); + assert!( + session + .pending_mcp_server_refresh_config + .lock() + .await + .is_none() + ); + let new_token = session.mcp_startup_cancellation_token().await; + assert!(!new_token.is_cancelled()); + } + #[tokio::test] async fn record_model_warning_appends_user_message() { let (mut session, turn_context) = make_session_and_context().await; diff --git a/codex-rs/core/src/command_safety/is_dangerous_command.rs b/codex-rs/core/src/command_safety/is_dangerous_command.rs index 014cd7c0fa..256f36c600 100644 --- a/codex-rs/core/src/command_safety/is_dangerous_command.rs +++ b/codex-rs/core/src/command_safety/is_dangerous_command.rs @@ -1,46 +1,8 @@ -use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::SandboxPolicy; - -use crate::sandboxing::SandboxPermissions; - use crate::bash::parse_shell_lc_plain_commands; -use crate::is_safe_command::is_known_safe_command; #[cfg(windows)] #[path = "windows_dangerous_commands.rs"] mod windows_dangerous_commands; -pub fn requires_initial_appoval( - policy: AskForApproval, - sandbox_policy: &SandboxPolicy, - command: &[String], - sandbox_permissions: SandboxPermissions, -) -> bool { - if is_known_safe_command(command) { - return false; - } - match policy { - AskForApproval::Never | AskForApproval::OnFailure => false, - AskForApproval::OnRequest => { - // In DangerFullAccess or ExternalSandbox, only prompt if the command looks dangerous. - if matches!( - sandbox_policy, - SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } - ) { - return command_might_be_dangerous(command); - } - - // In restricted sandboxes (ReadOnly/WorkspaceWrite), do not prompt for - // non‑escalated, non‑dangerous commands — let the sandbox enforce - // restrictions (e.g., block network/write) without a user prompt. - if sandbox_permissions.requires_escalated_permissions() { - return true; - } - command_might_be_dangerous(command) - } - AskForApproval::UnlessTrusted => !is_known_safe_command(command), - } -} - pub fn command_might_be_dangerous(command: &[String]) -> bool { #[cfg(windows)] { @@ -86,7 +48,6 @@ fn is_dangerous_to_call_with_exec(command: &[String]) -> bool { #[cfg(test)] mod tests { use super::*; - use codex_protocol::protocol::NetworkAccess; fn vec_str(items: &[&str]) -> Vec { items.iter().map(std::string::ToString::to_string).collect() @@ -154,23 +115,4 @@ mod tests { fn rm_f_is_dangerous() { assert!(command_might_be_dangerous(&vec_str(&["rm", "-f", "/"]))); } - - #[test] - fn external_sandbox_only_prompts_for_dangerous_commands() { - let external_policy = SandboxPolicy::ExternalSandbox { - network_access: NetworkAccess::Restricted, - }; - assert!(!requires_initial_appoval( - AskForApproval::OnRequest, - &external_policy, - &vec_str(&["ls"]), - SandboxPermissions::UseDefault, - )); - assert!(requires_initial_appoval( - AskForApproval::OnRequest, - &external_policy, - &vec_str(&["rm", "-rf", "/"]), - SandboxPermissions::UseDefault, - )); - } } diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index c8509cc5c5..120e701bbc 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -297,7 +297,8 @@ async fn drain_to_completed( turn_context: &TurnContext, prompt: &Prompt, ) -> CodexResult<()> { - let mut stream = turn_context.client.clone().stream(prompt).await?; + let mut client_session = turn_context.client.new_session(); + let mut stream = client_session.stream(prompt).await?; loop { let maybe_event = stream.next().await; let Some(event) = maybe_event else { diff --git a/codex-rs/core/src/config/constraint.rs b/codex-rs/core/src/config/constraint.rs index 5a412a0d0b..fa431a6eb2 100644 --- a/codex-rs/core/src/config/constraint.rs +++ b/codex-rs/core/src/config/constraint.rs @@ -37,11 +37,15 @@ impl From for std::io::Error { } type ConstraintValidator = dyn Fn(&T) -> ConstraintResult<()> + Send + Sync; +/// A ConstraintNormalizer is a function which transforms a value into another of the same type. +/// `Constrained` uses normalizers to transform values to satisfy constraints or enforce values. +type ConstraintNormalizer = dyn Fn(T) -> T + Send + Sync; #[derive(Clone)] pub struct Constrained { value: T, validator: Arc>, + normalizer: Option>>, } impl Constrained { @@ -54,6 +58,23 @@ impl Constrained { Ok(Self { value: initial_value, validator, + normalizer: None, + }) + } + + /// normalized creates a `Constrained` value with a normalizer function and a validator that allows any value. + pub fn normalized( + initial_value: T, + normalizer: impl Fn(T) -> T + Send + Sync + 'static, + ) -> ConstraintResult { + let validator: Arc> = Arc::new(|_| Ok(())); + let normalizer: Arc> = Arc::new(normalizer); + let normalized = normalizer(initial_value); + validator(&normalized)?; + Ok(Self { + value: normalized, + validator, + normalizer: Some(normalizer), }) } @@ -61,6 +82,7 @@ impl Constrained { Self { value: initial_value, validator: Arc::new(|_| Ok(())), + normalizer: None, } } @@ -88,6 +110,11 @@ impl Constrained { } pub fn set(&mut self, value: T) -> ConstraintResult<()> { + let value = if let Some(normalizer) = &self.normalizer { + normalizer(value) + } else { + value + }; (self.validator)(&value)?; self.value = value; Ok(()) @@ -143,6 +170,17 @@ mod tests { assert_eq!(constrained.value(), 0); } + #[test] + fn constrained_normalizer_applies_on_init_and_set() -> anyhow::Result<()> { + let mut constrained = Constrained::normalized(-1, |value| value.max(0))?; + assert_eq!(constrained.value(), 0); + constrained.set(-5)?; + assert_eq!(constrained.value(), 0); + constrained.set(10)?; + assert_eq!(constrained.value(), 10); + Ok(()) + } + #[test] fn constrained_new_rejects_invalid_initial_value() { let result = Constrained::new(0, |value| { diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 2a3185f8c4..91ff865d2b 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -2,6 +2,7 @@ use crate::auth::AuthCredentialsStoreMode; use crate::config::types::DEFAULT_OTEL_ENVIRONMENT; use crate::config::types::History; use crate::config::types::McpServerConfig; +use crate::config::types::McpServerTransportConfig; use crate::config::types::Notice; use crate::config::types::Notifications; use crate::config::types::OtelConfig; @@ -16,6 +17,8 @@ use crate::config::types::UriBasedFileOpener; use crate::config_loader::ConfigLayerStack; use crate::config_loader::ConfigRequirements; use crate::config_loader::LoaderOverrides; +use crate::config_loader::McpServerIdentity; +use crate::config_loader::McpServerRequirement; use crate::config_loader::load_config_layers_state; use crate::features::Feature; use crate::features::FeatureOverrides; @@ -24,6 +27,7 @@ use crate::features::FeaturesToml; use crate::git_info::resolve_root_git_project_for_trust; use crate::model_provider_info::LMSTUDIO_OSS_PROVIDER_ID; use crate::model_provider_info::ModelProviderInfo; +use crate::model_provider_info::OLLAMA_CHAT_PROVIDER_ID; use crate::model_provider_info::OLLAMA_OSS_PROVIDER_ID; use crate::model_provider_info::built_in_model_providers; use crate::project_doc::DEFAULT_PROJECT_DOC_FILENAME; @@ -43,6 +47,7 @@ use codex_rmcp_client::OAuthCredentialsStoreMode; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::AbsolutePathBufGuard; use dirs::home_dir; +use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use similar::DiffableStr; @@ -61,6 +66,7 @@ use toml_edit::DocumentMut; mod constraint; pub mod edit; pub mod profile; +pub mod schema; pub mod service; pub mod types; pub use constraint::Constrained; @@ -257,7 +263,7 @@ pub struct Config { pub cli_auth_credentials_store_mode: AuthCredentialsStoreMode, /// Definition for MCP servers that Codex can reach out to for tool calls. - pub mcp_servers: HashMap, + pub mcp_servers: Constrained>, /// Preferred store for MCP OAuth credentials. /// keyring: Use an OS-specific keyring service. @@ -540,6 +546,24 @@ fn deserialize_config_toml_lenient( } } +fn filter_mcp_servers_by_requirements( + mcp_servers: &mut HashMap, + mcp_requirements: Option<&BTreeMap>, +) { + let Some(allowlist) = mcp_requirements else { + return; + }; + + for (name, server) in mcp_servers.iter_mut() { + let allowed = allowlist + .get(name) + .is_some_and(|requirement| mcp_server_matches_requirement(requirement, server)); + if !allowed { + server.enabled = false; + } + } +} + fn deserialize_config_toml_with_path( value: &TomlValue, ) -> Result> { @@ -602,6 +626,41 @@ fn remove_toml_path(root: &mut TomlValue, path: &serde_path_to_error::Path) -> b } } +fn constrain_mcp_servers( + mcp_servers: HashMap, + mcp_requirements: Option<&BTreeMap>, +) -> ConstraintResult>> { + if mcp_requirements.is_none() { + return Ok(Constrained::allow_any(mcp_servers)); + } + + let mcp_requirements = mcp_requirements.cloned(); + Constrained::normalized(mcp_servers, move |mut servers| { + filter_mcp_servers_by_requirements(&mut servers, mcp_requirements.as_ref()); + servers + }) +} + +fn mcp_server_matches_requirement( + requirement: &McpServerRequirement, + server: &McpServerConfig, +) -> bool { + match &requirement.identity { + McpServerIdentity::Command { + command: want_command, + } => matches!( + &server.transport, + McpServerTransportConfig::Stdio { command: got_command, .. } + if got_command == want_command + ), + McpServerIdentity::Url { url: want_url } => matches!( + &server.transport, + McpServerTransportConfig::StreamableHttp { url: got_url, .. } + if got_url == want_url + ), + } +} + pub async fn load_global_mcp_servers( codex_home: &Path, ) -> std::io::Result> { @@ -740,14 +799,14 @@ pub fn set_project_trust_level( pub fn set_default_oss_provider(codex_home: &Path, provider: &str) -> std::io::Result<()> { // Validate that the provider is one of the known OSS providers match provider { - LMSTUDIO_OSS_PROVIDER_ID | OLLAMA_OSS_PROVIDER_ID => { + LMSTUDIO_OSS_PROVIDER_ID | OLLAMA_OSS_PROVIDER_ID | OLLAMA_CHAT_PROVIDER_ID => { // Valid provider, continue } _ => { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, format!( - "Invalid OSS provider '{provider}'. Must be one of: {LMSTUDIO_OSS_PROVIDER_ID}, {OLLAMA_OSS_PROVIDER_ID}" + "Invalid OSS provider '{provider}'. Must be one of: {LMSTUDIO_OSS_PROVIDER_ID}, {OLLAMA_OSS_PROVIDER_ID}, {OLLAMA_CHAT_PROVIDER_ID}" ), )); } @@ -779,7 +838,8 @@ pub fn set_default_oss_provider(codex_home: &Path, provider: &str) -> std::io::R } /// Base config deserialized from ~/.codex/config.toml. -#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)] +#[schemars(deny_unknown_fields)] pub struct ConfigToml { /// Optional override of model selection. pub model: Option, @@ -840,6 +900,8 @@ pub struct ConfigToml { /// Definition for MCP servers that Codex can reach out to for tool calls. #[serde(default)] + // Uses the raw MCP input shape (custom deserialization) rather than `McpServerConfig`. + #[schemars(schema_with = "crate::config::schema::mcp_servers_schema")] pub mcp_servers: HashMap, /// Preferred backend for storing MCP OAuth credentials. @@ -915,6 +977,8 @@ pub struct ConfigToml { /// Centralized feature flags (new). Prefer this over individual toggles. #[serde(default)] + // Injects known feature keys into the schema and forbids unknown keys. + #[schemars(schema_with = "crate::config::schema::features_schema")] pub features: Option, /// Settings for ghost snapshots (used for undo). @@ -959,7 +1023,7 @@ pub struct ConfigToml { pub experimental_compact_prompt_file: Option, pub experimental_use_unified_exec_tool: Option, pub experimental_use_freeform_apply_patch: Option, - /// Preferred OSS provider for local models, e.g. "lmstudio" or "ollama". + /// Preferred OSS provider for local models, e.g. "lmstudio", "ollama", or "ollama-chat". pub oss_provider: Option, } @@ -988,7 +1052,8 @@ impl From for UserSavedConfig { } } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] pub struct ProjectConfig { #[serde(default)] pub trust_level: Option, @@ -1004,7 +1069,8 @@ impl ProjectConfig { } } -#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)] +#[schemars(deny_unknown_fields)] pub struct ToolsToml { #[serde(default, alias = "web_search_request")] pub web_search: Option, @@ -1023,7 +1089,8 @@ impl From for Tools { } } -#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] pub struct GhostSnapshotToml { /// Exclude untracked files larger than this many bytes from ghost snapshots. #[serde(alias = "ignore_untracked_files_over_bytes")] @@ -1435,6 +1502,7 @@ impl Config { let ConfigRequirements { approval_policy: mut constrained_approval_policy, sandbox_policy: mut constrained_sandbox_policy, + mcp_server_requirements, } = requirements; constrained_approval_policy @@ -1444,6 +1512,12 @@ impl Config { .set(sandbox_policy) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{e}")))?; + let mcp_servers = + constrain_mcp_servers(cfg.mcp_servers.clone(), mcp_server_requirements.as_ref()) + .map_err(|e| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{e}")) + })?; + let config = Self { model, review_model, @@ -1465,7 +1539,7 @@ impl Config { // The config.toml omits "_mode" because it's a config file. However, "_mode" // is important in code to differentiate the mode from the store implementation. cli_auth_credentials_store_mode: cfg.cli_auth_credentials_store.unwrap_or_default(), - mcp_servers: cfg.mcp_servers, + mcp_servers, // The config.toml omits "_mode" because it's a config file. However, "_mode" // is important in code to differentiate the mode from the store implementation. mcp_oauth_credentials_store_mode: cfg.mcp_oauth_credentials_store.unwrap_or_default(), @@ -1704,9 +1778,44 @@ mod tests { use core_test_support::test_absolute_path; use pretty_assertions::assert_eq; + use std::collections::BTreeMap; + use std::collections::HashMap; use std::time::Duration; use tempfile::TempDir; + fn stdio_mcp(command: &str) -> McpServerConfig { + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: command.to_string(), + args: Vec::new(), + env: None, + env_vars: Vec::new(), + cwd: None, + }, + enabled: true, + startup_timeout_sec: None, + tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, + } + } + + fn http_mcp(url: &str) -> McpServerConfig { + McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: url.to_string(), + bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, + }, + enabled: true, + startup_timeout_sec: None, + tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, + } + } + #[test] fn test_toml_parsing() { let history_with_persistence = r#" @@ -1934,6 +2043,122 @@ trust_level = "trusted" } } + #[test] + fn filter_mcp_servers_by_allowlist_enforces_identity_rules() { + const MISMATCHED_COMMAND_SERVER: &str = "mismatched-command-should-disable"; + const MISMATCHED_URL_SERVER: &str = "mismatched-url-should-disable"; + const MATCHED_COMMAND_SERVER: &str = "matched-command-should-allow"; + const MATCHED_URL_SERVER: &str = "matched-url-should-allow"; + const DIFFERENT_NAME_SERVER: &str = "different-name-should-disable"; + + const GOOD_CMD: &str = "good-cmd"; + const GOOD_URL: &str = "https://example.com/good"; + + let mut servers = HashMap::from([ + (MISMATCHED_COMMAND_SERVER.to_string(), stdio_mcp("docs-cmd")), + ( + MISMATCHED_URL_SERVER.to_string(), + http_mcp("https://example.com/mcp"), + ), + (MATCHED_COMMAND_SERVER.to_string(), stdio_mcp(GOOD_CMD)), + (MATCHED_URL_SERVER.to_string(), http_mcp(GOOD_URL)), + (DIFFERENT_NAME_SERVER.to_string(), stdio_mcp("same-cmd")), + ]); + filter_mcp_servers_by_requirements( + &mut servers, + Some(&BTreeMap::from([ + ( + MISMATCHED_URL_SERVER.to_string(), + McpServerRequirement { + identity: McpServerIdentity::Url { + url: "https://example.com/other".to_string(), + }, + }, + ), + ( + MISMATCHED_COMMAND_SERVER.to_string(), + McpServerRequirement { + identity: McpServerIdentity::Command { + command: "other-cmd".to_string(), + }, + }, + ), + ( + MATCHED_URL_SERVER.to_string(), + McpServerRequirement { + identity: McpServerIdentity::Url { + url: GOOD_URL.to_string(), + }, + }, + ), + ( + MATCHED_COMMAND_SERVER.to_string(), + McpServerRequirement { + identity: McpServerIdentity::Command { + command: GOOD_CMD.to_string(), + }, + }, + ), + ])), + ); + + assert_eq!( + servers + .iter() + .map(|(name, server)| (name.clone(), server.enabled)) + .collect::>(), + HashMap::from([ + (MISMATCHED_URL_SERVER.to_string(), false), + (MISMATCHED_COMMAND_SERVER.to_string(), false), + (MATCHED_URL_SERVER.to_string(), true), + (MATCHED_COMMAND_SERVER.to_string(), true), + (DIFFERENT_NAME_SERVER.to_string(), false), + ]) + ); + } + + #[test] + fn filter_mcp_servers_by_allowlist_allows_all_when_unset() { + let mut servers = HashMap::from([ + ("server-a".to_string(), stdio_mcp("cmd-a")), + ("server-b".to_string(), http_mcp("https://example.com/b")), + ]); + + filter_mcp_servers_by_requirements(&mut servers, None); + + assert_eq!( + servers + .iter() + .map(|(name, server)| (name.clone(), server.enabled)) + .collect::>(), + HashMap::from([ + ("server-a".to_string(), true), + ("server-b".to_string(), true), + ]) + ); + } + + #[test] + fn filter_mcp_servers_by_allowlist_blocks_all_when_empty() { + let mut servers = HashMap::from([ + ("server-a".to_string(), stdio_mcp("cmd-a")), + ("server-b".to_string(), http_mcp("https://example.com/b")), + ]); + + filter_mcp_servers_by_requirements(&mut servers, Some(&BTreeMap::new())); + + assert_eq!( + servers + .iter() + .map(|(name, server)| (name.clone(), server.enabled)) + .collect::>(), + HashMap::from([ + ("server-a".to_string(), false), + ("server-b".to_string(), false), + ]) + ); + } + #[test] fn add_dir_override_extends_workspace_writable_roots() -> std::io::Result<()> { let temp_dir = TempDir::new()?; @@ -3375,7 +3600,7 @@ model_verbosity = "high" notify: None, cwd: fixture.cwd(), cli_auth_credentials_store_mode: Default::default(), - mcp_servers: HashMap::new(), + mcp_servers: Constrained::allow_any(HashMap::new()), mcp_oauth_credentials_store_mode: Default::default(), mcp_oauth_callback_port: None, model_providers: fixture.model_provider_map.clone(), @@ -3462,7 +3687,7 @@ model_verbosity = "high" notify: None, cwd: fixture.cwd(), cli_auth_credentials_store_mode: Default::default(), - mcp_servers: HashMap::new(), + mcp_servers: Constrained::allow_any(HashMap::new()), mcp_oauth_credentials_store_mode: Default::default(), mcp_oauth_callback_port: None, model_providers: fixture.model_provider_map.clone(), @@ -3564,7 +3789,7 @@ model_verbosity = "high" notify: None, cwd: fixture.cwd(), cli_auth_credentials_store_mode: Default::default(), - mcp_servers: HashMap::new(), + mcp_servers: Constrained::allow_any(HashMap::new()), mcp_oauth_credentials_store_mode: Default::default(), mcp_oauth_callback_port: None, model_providers: fixture.model_provider_map.clone(), @@ -3652,7 +3877,7 @@ model_verbosity = "high" notify: None, cwd: fixture.cwd(), cli_auth_credentials_store_mode: Default::default(), - mcp_servers: HashMap::new(), + mcp_servers: Constrained::allow_any(HashMap::new()), mcp_oauth_credentials_store_mode: Default::default(), mcp_oauth_callback_port: None, model_providers: fixture.model_provider_map.clone(), diff --git a/codex-rs/core/src/config/profile.rs b/codex-rs/core/src/config/profile.rs index ade8f98e7b..ec1e08d45a 100644 --- a/codex-rs/core/src/config/profile.rs +++ b/codex-rs/core/src/config/profile.rs @@ -1,4 +1,5 @@ use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; @@ -10,7 +11,8 @@ use codex_protocol::openai_models::ReasoningEffort; /// Collection of common configuration options that a user can define as a unit /// in `config.toml`. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +#[schemars(deny_unknown_fields)] pub struct ConfigProfile { pub model: Option, /// The key in the `model_providers` map identifying the @@ -37,6 +39,8 @@ pub struct ConfigProfile { pub analytics: Option, /// Optional feature toggles scoped to this profile. #[serde(default)] + // Injects known feature keys into the schema and forbids unknown keys. + #[schemars(schema_with = "crate::config::schema::features_schema")] pub features: Option, pub oss_provider: Option, } diff --git a/codex-rs/core/src/config/schema.md b/codex-rs/core/src/config/schema.md new file mode 100644 index 0000000000..101c57b363 --- /dev/null +++ b/codex-rs/core/src/config/schema.md @@ -0,0 +1,11 @@ +# Config JSON Schema + +We generate a JSON Schema for `~/.codex/config.toml` from the `ConfigToml` type +and commit it at `codex-rs/core/config.schema.json` for editor integration. + +When you change any fields included in `ConfigToml` (or nested config types), +regenerate the schema: + +``` +just write-config-schema +``` diff --git a/codex-rs/core/src/config/schema.rs b/codex-rs/core/src/config/schema.rs new file mode 100644 index 0000000000..674dca3aaf --- /dev/null +++ b/codex-rs/core/src/config/schema.rs @@ -0,0 +1,127 @@ +use crate::config::ConfigToml; +use crate::config::types::RawMcpServerConfig; +use crate::features::FEATURES; +use schemars::r#gen::SchemaGenerator; +use schemars::r#gen::SchemaSettings; +use schemars::schema::InstanceType; +use schemars::schema::ObjectValidation; +use schemars::schema::RootSchema; +use schemars::schema::Schema; +use schemars::schema::SchemaObject; +use std::path::Path; + +/// Schema for the `[features]` map with known + legacy keys only. +pub(crate) fn features_schema(schema_gen: &mut SchemaGenerator) -> Schema { + let mut object = SchemaObject { + instance_type: Some(InstanceType::Object.into()), + ..Default::default() + }; + + let mut validation = ObjectValidation::default(); + for feature in FEATURES { + validation + .properties + .insert(feature.key.to_string(), schema_gen.subschema_for::()); + } + for legacy_key in crate::features::legacy_feature_keys() { + validation + .properties + .insert(legacy_key.to_string(), schema_gen.subschema_for::()); + } + validation.additional_properties = Some(Box::new(Schema::Bool(false))); + object.object = Some(Box::new(validation)); + + Schema::Object(object) +} + +/// Schema for the `[mcp_servers]` map using the raw input shape. +pub(crate) fn mcp_servers_schema(schema_gen: &mut SchemaGenerator) -> Schema { + let mut object = SchemaObject { + instance_type: Some(InstanceType::Object.into()), + ..Default::default() + }; + + let validation = ObjectValidation { + additional_properties: Some(Box::new(schema_gen.subschema_for::())), + ..Default::default() + }; + object.object = Some(Box::new(validation)); + + Schema::Object(object) +} + +/// Build the config schema for `config.toml`. +pub fn config_schema() -> RootSchema { + SchemaSettings::draft07() + .with(|settings| { + settings.option_add_null_type = false; + }) + .into_generator() + .into_root_schema_for::() +} + +/// Render the config schema as pretty-printed JSON. +pub fn config_schema_json() -> anyhow::Result> { + let schema = config_schema(); + let json = serde_json::to_vec_pretty(&schema)?; + Ok(json) +} + +/// Write the config schema fixture to disk. +pub fn write_config_schema(out_path: &Path) -> anyhow::Result<()> { + let json = config_schema_json()?; + std::fs::write(out_path, json)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::config_schema_json; + use serde_json::Map; + use serde_json::Value; + use similar::TextDiff; + + fn canonicalize(value: &Value) -> Value { + match value { + Value::Array(items) => Value::Array(items.iter().map(canonicalize).collect()), + Value::Object(map) => { + let mut entries: Vec<_> = map.iter().collect(); + entries.sort_by(|(left, _), (right, _)| left.cmp(right)); + let mut sorted = Map::with_capacity(map.len()); + for (key, child) in entries { + sorted.insert(key.clone(), canonicalize(child)); + } + Value::Object(sorted) + } + _ => value.clone(), + } + } + + #[test] + fn config_schema_matches_fixture() { + let fixture_path = codex_utils_cargo_bin::find_resource!("config.schema.json") + .expect("resolve config schema fixture path"); + let fixture = std::fs::read_to_string(fixture_path).expect("read config schema fixture"); + let fixture_value: serde_json::Value = + serde_json::from_str(&fixture).expect("parse config schema fixture"); + let schema_json = config_schema_json().expect("serialize config schema"); + let schema_value: serde_json::Value = + serde_json::from_slice(&schema_json).expect("decode schema json"); + let fixture_value = canonicalize(&fixture_value); + let schema_value = canonicalize(&schema_value); + if fixture_value != schema_value { + let expected = + serde_json::to_string_pretty(&fixture_value).expect("serialize fixture json"); + let actual = + serde_json::to_string_pretty(&schema_value).expect("serialize schema json"); + let diff = TextDiff::from_lines(&expected, &actual) + .unified_diff() + .header("fixture", "generated") + .to_string(); + panic!( + "Current schema for `config.toml` doesn't match the fixture. \ +Run `just write-config-schema` to overwrite with your changes.\n\n{diff}" + ); + } + } +} diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index bf6e42271d..dfceac562b 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -11,6 +11,7 @@ use std::path::PathBuf; use std::time::Duration; use wildmatch::WildMatchPattern; +use schemars::JsonSchema; use serde::Deserialize; use serde::Deserializer; use serde::Serialize; @@ -48,47 +49,51 @@ pub struct McpServerConfig { pub disabled_tools: Option>, } +// Raw MCP config shape used for deserialization and JSON Schema generation. +// Keep this in sync with the validation logic in `McpServerConfig`. +#[derive(Deserialize, Clone, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub(crate) struct RawMcpServerConfig { + // stdio + pub command: Option, + #[serde(default)] + pub args: Option>, + #[serde(default)] + pub env: Option>, + #[serde(default)] + pub env_vars: Option>, + #[serde(default)] + pub cwd: Option, + pub http_headers: Option>, + #[serde(default)] + pub env_http_headers: Option>, + + // streamable_http + pub url: Option, + pub bearer_token: Option, + pub bearer_token_env_var: Option, + + // shared + #[serde(default)] + pub startup_timeout_sec: Option, + #[serde(default)] + pub startup_timeout_ms: Option, + #[serde(default, with = "option_duration_secs")] + #[schemars(with = "Option")] + pub tool_timeout_sec: Option, + #[serde(default)] + pub enabled: Option, + #[serde(default)] + pub enabled_tools: Option>, + #[serde(default)] + pub disabled_tools: Option>, +} + impl<'de> Deserialize<'de> for McpServerConfig { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { - #[derive(Deserialize, Clone)] - struct RawMcpServerConfig { - // stdio - command: Option, - #[serde(default)] - args: Option>, - #[serde(default)] - env: Option>, - #[serde(default)] - env_vars: Option>, - #[serde(default)] - cwd: Option, - http_headers: Option>, - #[serde(default)] - env_http_headers: Option>, - - // streamable_http - url: Option, - bearer_token: Option, - bearer_token_env_var: Option, - - // shared - #[serde(default)] - startup_timeout_sec: Option, - #[serde(default)] - startup_timeout_ms: Option, - #[serde(default, with = "option_duration_secs")] - tool_timeout_sec: Option, - #[serde(default)] - enabled: Option, - #[serde(default)] - enabled_tools: Option>, - #[serde(default)] - disabled_tools: Option>, - } - let mut raw = RawMcpServerConfig::deserialize(deserializer)?; let startup_timeout_sec = match (raw.startup_timeout_sec, raw.startup_timeout_ms) { @@ -164,7 +169,7 @@ const fn default_enabled() -> bool { true } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)] #[serde(untagged, deny_unknown_fields, rename_all = "snake_case")] pub enum McpServerTransportConfig { /// https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#stdio @@ -222,7 +227,7 @@ mod option_duration_secs { } } -#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, JsonSchema)] pub enum UriBasedFileOpener { #[serde(rename = "vscode")] VsCode, @@ -254,7 +259,8 @@ impl UriBasedFileOpener { } /// Settings that govern if and what will be written to `~/.codex/history.jsonl`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] pub struct History { /// If true, history entries will not be written to disk. pub persistence: HistoryPersistence, @@ -264,7 +270,7 @@ pub struct History { pub max_bytes: Option, } -#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Default)] +#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Default, JsonSchema)] #[serde(rename_all = "kebab-case")] pub enum HistoryPersistence { /// Save all history entries to disk. @@ -277,13 +283,15 @@ pub enum HistoryPersistence { // ===== Analytics configuration ===== /// Analytics settings loaded from config.toml. Fields are optional so we can apply defaults. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] pub struct AnalyticsConfigToml { /// When `false`, disables analytics across Codex product surfaces in this profile. pub enabled: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] pub struct FeedbackConfigToml { /// When `false`, disables the feedback flow across Codex product surfaces. pub enabled: Option, @@ -291,7 +299,7 @@ pub struct FeedbackConfigToml { // ===== OTEL configuration ===== -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)] #[serde(rename_all = "kebab-case")] pub enum OtelHttpProtocol { /// Binary payload @@ -300,7 +308,8 @@ pub enum OtelHttpProtocol { Json, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] #[serde(rename_all = "kebab-case")] pub struct OtelTlsConfig { pub ca_certificate: Option, @@ -309,7 +318,8 @@ pub struct OtelTlsConfig { } /// Which OTEL exporter to use. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)] +#[schemars(deny_unknown_fields)] #[serde(rename_all = "kebab-case")] pub enum OtelExporterKind { None, @@ -332,7 +342,8 @@ pub enum OtelExporterKind { } /// OTEL settings loaded from config.toml. Fields are optional so we can apply defaults. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] pub struct OtelConfigToml { /// Log user prompt in traces pub log_user_prompt: Option, @@ -371,7 +382,7 @@ impl Default for OtelConfig { } } -#[derive(Serialize, Debug, Clone, PartialEq, Eq, Deserialize)] +#[derive(Serialize, Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] #[serde(untagged)] pub enum Notifications { Enabled(bool), @@ -389,7 +400,7 @@ impl Default for Notifications { /// Terminals generally encode both mouse wheels and trackpads as the same "scroll up/down" mouse /// button events, without a magnitude. This setting controls whether Codex uses a heuristic to /// infer wheel vs trackpad per stream, or forces a specific behavior. -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum ScrollInputMode { /// Infer wheel vs trackpad behavior per scroll stream. @@ -407,7 +418,8 @@ impl Default for ScrollInputMode { } /// Collection of settings that are specific to the TUI. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] pub struct Tui { /// Enable desktop notifications from the TUI when the terminal is unfocused. /// Defaults to `true`. @@ -546,7 +558,8 @@ const fn default_true() -> bool { /// Settings for notices we display to users via the tui and app-server clients /// (primarily the Codex IDE extension). NOTE: these are different from /// notifications - notices are warnings, NUX screens, acknowledgements, etc. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] pub struct Notice { /// Tracks whether the user has acknowledged the full access warning prompt. pub hide_full_access_warning: Option, @@ -569,7 +582,8 @@ impl Notice { pub(crate) const TABLE_KEY: &'static str = "notice"; } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] pub struct SandboxWorkspaceWrite { #[serde(default)] pub writable_roots: Vec, @@ -592,7 +606,7 @@ impl From for codex_app_server_protocol::SandboxSettings } } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] #[serde(rename_all = "kebab-case")] pub enum ShellEnvironmentPolicyInherit { /// "Core" environment variables for the platform. On UNIX, this would @@ -609,7 +623,8 @@ pub enum ShellEnvironmentPolicyInherit { /// Policy for building the `env` when spawning a process via either the /// `shell` or `local_shell` tool. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] pub struct ShellEnvironmentPolicyToml { #[serde(default)] pub inherit: Option, diff --git a/codex-rs/core/src/config_loader/config_requirements.rs b/codex-rs/core/src/config_loader/config_requirements.rs index dd001e417f..731ff7d790 100644 --- a/codex-rs/core/src/config_loader/config_requirements.rs +++ b/codex-rs/core/src/config_loader/config_requirements.rs @@ -3,6 +3,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use serde::Deserialize; +use std::collections::BTreeMap; use std::fmt; use crate::config::Constrained; @@ -43,6 +44,7 @@ impl fmt::Display for RequirementSource { pub struct ConfigRequirements { pub approval_policy: Constrained, pub sandbox_policy: Constrained, + pub mcp_server_requirements: Option>, } impl Default for ConfigRequirements { @@ -50,15 +52,29 @@ impl Default for ConfigRequirements { Self { approval_policy: Constrained::allow_any_from_default(), sandbox_policy: Constrained::allow_any(SandboxPolicy::ReadOnly), + mcp_server_requirements: None, } } } +#[derive(Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(untagged)] +pub enum McpServerIdentity { + Command { command: String }, + Url { url: String }, +} + +#[derive(Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct McpServerRequirement { + pub identity: McpServerIdentity, +} + /// Base config deserialized from /etc/codex/requirements.toml or MDM. #[derive(Deserialize, Debug, Clone, Default, PartialEq)] pub struct ConfigRequirementsToml { pub allowed_approval_policies: Option>, pub allowed_sandbox_modes: Option>, + pub mcp_server_requirements: Option>, } /// Value paired with the requirement source it came from, for better error @@ -87,6 +103,7 @@ impl std::ops::Deref for Sourced { pub struct ConfigRequirementsWithSources { pub allowed_approval_policies: Option>>, pub allowed_sandbox_modes: Option>>, + pub mcp_server_requirements: Option>>, } impl ConfigRequirementsWithSources { @@ -114,7 +131,11 @@ impl ConfigRequirementsWithSources { self, other, source, - { allowed_approval_policies, allowed_sandbox_modes } + { + allowed_approval_policies, + allowed_sandbox_modes, + mcp_server_requirements, + } ); } @@ -122,10 +143,12 @@ impl ConfigRequirementsWithSources { let ConfigRequirementsWithSources { allowed_approval_policies, allowed_sandbox_modes, + mcp_server_requirements, } = self; ConfigRequirementsToml { allowed_approval_policies: allowed_approval_policies.map(|sourced| sourced.value), allowed_sandbox_modes: allowed_sandbox_modes.map(|sourced| sourced.value), + mcp_server_requirements: mcp_server_requirements.map(|sourced| sourced.value), } } } @@ -159,7 +182,9 @@ impl From for SandboxModeRequirement { impl ConfigRequirementsToml { pub fn is_empty(&self) -> bool { - self.allowed_approval_policies.is_none() && self.allowed_sandbox_modes.is_none() + self.allowed_approval_policies.is_none() + && self.allowed_sandbox_modes.is_none() + && self.mcp_server_requirements.is_none() } } @@ -170,6 +195,7 @@ impl TryFrom for ConfigRequirements { let ConfigRequirementsWithSources { allowed_approval_policies, allowed_sandbox_modes, + mcp_server_requirements, } = toml; let approval_policy: Constrained = match allowed_approval_policies { @@ -247,6 +273,7 @@ impl TryFrom for ConfigRequirements { Ok(ConfigRequirements { approval_policy, sandbox_policy, + mcp_server_requirements: mcp_server_requirements.map(|sourced| sourced.value), }) } } @@ -264,12 +291,15 @@ mod tests { let ConfigRequirementsToml { allowed_approval_policies, allowed_sandbox_modes, + mcp_server_requirements, } = toml; ConfigRequirementsWithSources { allowed_approval_policies: allowed_approval_policies .map(|value| Sourced::new(value, RequirementSource::Unknown)), allowed_sandbox_modes: allowed_sandbox_modes .map(|value| Sourced::new(value, RequirementSource::Unknown)), + mcp_server_requirements: mcp_server_requirements + .map(|value| Sourced::new(value, RequirementSource::Unknown)), } } @@ -289,6 +319,7 @@ mod tests { let other = ConfigRequirementsToml { allowed_approval_policies: Some(allowed_approval_policies.clone()), allowed_sandbox_modes: Some(allowed_sandbox_modes.clone()), + mcp_server_requirements: None, }; target.merge_unset_fields(source.clone(), other); @@ -301,6 +332,7 @@ mod tests { source.clone() )), allowed_sandbox_modes: Some(Sourced::new(allowed_sandbox_modes, source)), + mcp_server_requirements: None, } ); } @@ -328,6 +360,7 @@ mod tests { source_location, )), allowed_sandbox_modes: None, + mcp_server_requirements: None, } ); Ok(()) @@ -363,6 +396,7 @@ mod tests { existing_source, )), allowed_sandbox_modes: None, + mcp_server_requirements: None, } ); Ok(()) @@ -523,4 +557,40 @@ mod tests { Ok(()) } + + #[test] + fn deserialize_mcp_server_requirements() -> Result<()> { + let toml_str = r#" + [mcp_server_requirements.docs.identity] + command = "codex-mcp" + + [mcp_server_requirements.remote.identity] + url = "https://example.com/mcp" + "#; + let requirements: ConfigRequirements = + with_unknown_source(from_str(toml_str)?).try_into()?; + + assert_eq!( + requirements.mcp_server_requirements, + Some(BTreeMap::from([ + ( + "docs".to_string(), + McpServerRequirement { + identity: McpServerIdentity::Command { + command: "codex-mcp".to_string(), + }, + }, + ), + ( + "remote".to_string(), + McpServerRequirement { + identity: McpServerIdentity::Url { + url: "https://example.com/mcp".to_string(), + }, + }, + ), + ])) + ); + Ok(()) + } } diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index 1710ec12c5..a793aa2236 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -26,6 +26,8 @@ use toml::Value as TomlValue; pub use config_requirements::ConfigRequirements; pub use config_requirements::ConfigRequirementsToml; +pub use config_requirements::McpServerIdentity; +pub use config_requirements::McpServerRequirement; pub use config_requirements::RequirementSource; pub use config_requirements::SandboxModeRequirement; pub use merge::merge_toml_values; diff --git a/codex-rs/core/src/environment_context.rs b/codex-rs/core/src/environment_context.rs index 6a0e0f26cd..3e340ebbd3 100644 --- a/codex-rs/core/src/environment_context.rs +++ b/codex-rs/core/src/environment_context.rs @@ -1,14 +1,9 @@ use crate::codex::TurnContext; -use crate::protocol::AskForApproval; -use crate::protocol::NetworkAccess; -use crate::protocol::SandboxPolicy; use crate::shell::Shell; -use codex_protocol::config_types::SandboxMode; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG; use codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG; -use codex_utils_absolute_path::AbsolutePathBuf; use serde::Deserialize; use serde::Serialize; use std::path::PathBuf; @@ -17,55 +12,12 @@ use std::path::PathBuf; #[serde(rename = "environment_context", rename_all = "snake_case")] pub(crate) struct EnvironmentContext { pub cwd: Option, - pub approval_policy: Option, - pub sandbox_mode: Option, - pub network_access: Option, - pub writable_roots: Option>, pub shell: Shell, } impl EnvironmentContext { - pub fn new( - cwd: Option, - approval_policy: Option, - sandbox_policy: Option, - shell: Shell, - ) -> Self { - Self { - cwd, - approval_policy, - sandbox_mode: match sandbox_policy { - Some(SandboxPolicy::DangerFullAccess) => Some(SandboxMode::DangerFullAccess), - Some(SandboxPolicy::ReadOnly) => Some(SandboxMode::ReadOnly), - Some(SandboxPolicy::ExternalSandbox { .. }) => Some(SandboxMode::DangerFullAccess), - Some(SandboxPolicy::WorkspaceWrite { .. }) => Some(SandboxMode::WorkspaceWrite), - None => None, - }, - network_access: match sandbox_policy { - Some(SandboxPolicy::DangerFullAccess) => Some(NetworkAccess::Enabled), - Some(SandboxPolicy::ReadOnly) => Some(NetworkAccess::Restricted), - Some(SandboxPolicy::ExternalSandbox { network_access }) => Some(network_access), - Some(SandboxPolicy::WorkspaceWrite { network_access, .. }) => { - if network_access { - Some(NetworkAccess::Enabled) - } else { - Some(NetworkAccess::Restricted) - } - } - None => None, - }, - writable_roots: match sandbox_policy { - Some(SandboxPolicy::WorkspaceWrite { writable_roots, .. }) => { - if writable_roots.is_empty() { - None - } else { - Some(writable_roots) - } - } - _ => None, - }, - shell, - } + pub fn new(cwd: Option, shell: Shell) -> Self { + Self { cwd, shell } } /// Compares two environment contexts, ignoring the shell. Useful when @@ -74,19 +26,11 @@ impl EnvironmentContext { pub fn equals_except_shell(&self, other: &EnvironmentContext) -> bool { let EnvironmentContext { cwd, - approval_policy, - sandbox_mode, - network_access, - writable_roots, // should compare all fields except shell shell: _, } = other; self.cwd == *cwd - && self.approval_policy == *approval_policy - && self.sandbox_mode == *sandbox_mode - && self.network_access == *network_access - && self.writable_roots == *writable_roots } pub fn diff(before: &TurnContext, after: &TurnContext, shell: &Shell) -> Self { @@ -95,26 +39,11 @@ impl EnvironmentContext { } else { None }; - let approval_policy = if before.approval_policy != after.approval_policy { - Some(after.approval_policy) - } else { - None - }; - let sandbox_policy = if before.sandbox_policy != after.sandbox_policy { - Some(after.sandbox_policy.clone()) - } else { - None - }; - EnvironmentContext::new(cwd, approval_policy, sandbox_policy, shell.clone()) + EnvironmentContext::new(cwd, shell.clone()) } pub fn from_turn_context(turn_context: &TurnContext, shell: &Shell) -> Self { - Self::new( - Some(turn_context.cwd.clone()), - Some(turn_context.approval_policy), - Some(turn_context.sandbox_policy.clone()), - shell.clone(), - ) + Self::new(Some(turn_context.cwd.clone()), shell.clone()) } } @@ -126,10 +55,6 @@ impl EnvironmentContext { /// ```xml /// /// ... - /// ... - /// ... - /// ... - /// ... /// ... /// /// ``` @@ -138,29 +63,6 @@ impl EnvironmentContext { if let Some(cwd) = self.cwd { lines.push(format!(" {}", cwd.to_string_lossy())); } - if let Some(approval_policy) = self.approval_policy { - lines.push(format!( - " {approval_policy}" - )); - } - if let Some(sandbox_mode) = self.sandbox_mode { - lines.push(format!(" {sandbox_mode}")); - } - if let Some(network_access) = self.network_access { - lines.push(format!( - " {network_access}" - )); - } - if let Some(writable_roots) = self.writable_roots { - lines.push(" ".to_string()); - for writable_root in writable_roots { - lines.push(format!( - " {}", - writable_root.to_string_lossy() - )); - } - lines.push(" ".to_string()); - } let shell_name = self.shell.name(); lines.push(format!(" {shell_name}")); @@ -187,7 +89,6 @@ mod tests { use super::*; use core_test_support::test_path_buf; - use core_test_support::test_tmp_path_buf; use pretty_assertions::assert_eq; fn fake_shell() -> Shell { @@ -198,50 +99,17 @@ mod tests { } } - fn workspace_write_policy(writable_roots: Vec<&str>, network_access: bool) -> SandboxPolicy { - SandboxPolicy::WorkspaceWrite { - writable_roots: writable_roots - .into_iter() - .map(|s| AbsolutePathBuf::try_from(s).unwrap()) - .collect(), - network_access, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - } - } - #[test] fn serialize_workspace_write_environment_context() { let cwd = test_path_buf("/repo"); - let writable_root = test_tmp_path_buf(); - let cwd_str = cwd.to_str().expect("cwd is valid utf-8"); - let writable_root_str = writable_root - .to_str() - .expect("writable root is valid utf-8"); - let context = EnvironmentContext::new( - Some(cwd.clone()), - Some(AskForApproval::OnRequest), - Some(workspace_write_policy( - vec![cwd_str, writable_root_str], - false, - )), - fake_shell(), - ); + let context = EnvironmentContext::new(Some(cwd.clone()), fake_shell()); let expected = format!( r#" {cwd} - on-request - workspace-write - restricted - - {cwd} - {writable_root} - bash "#, cwd = cwd.display(), - writable_root = writable_root.display(), ); assert_eq!(context.serialize_to_xml(), expected); @@ -249,17 +117,9 @@ mod tests { #[test] fn serialize_read_only_environment_context() { - let context = EnvironmentContext::new( - None, - Some(AskForApproval::Never), - Some(SandboxPolicy::ReadOnly), - fake_shell(), - ); + let context = EnvironmentContext::new(None, fake_shell()); let expected = r#" - never - read-only - restricted bash "#; @@ -268,19 +128,9 @@ mod tests { #[test] fn serialize_external_sandbox_environment_context() { - let context = EnvironmentContext::new( - None, - Some(AskForApproval::OnRequest), - Some(SandboxPolicy::ExternalSandbox { - network_access: NetworkAccess::Enabled, - }), - fake_shell(), - ); + let context = EnvironmentContext::new(None, fake_shell()); let expected = r#" - on-request - danger-full-access - enabled bash "#; @@ -289,19 +139,9 @@ mod tests { #[test] fn serialize_external_sandbox_with_restricted_network_environment_context() { - let context = EnvironmentContext::new( - None, - Some(AskForApproval::OnRequest), - Some(SandboxPolicy::ExternalSandbox { - network_access: NetworkAccess::Restricted, - }), - fake_shell(), - ); + let context = EnvironmentContext::new(None, fake_shell()); let expected = r#" - on-request - danger-full-access - restricted bash "#; @@ -310,17 +150,9 @@ mod tests { #[test] fn serialize_full_access_environment_context() { - let context = EnvironmentContext::new( - None, - Some(AskForApproval::OnFailure), - Some(SandboxPolicy::DangerFullAccess), - fake_shell(), - ); + let context = EnvironmentContext::new(None, fake_shell()); let expected = r#" - on-failure - danger-full-access - enabled bash "#; @@ -328,55 +160,24 @@ mod tests { } #[test] - fn equals_except_shell_compares_approval_policy() { - // Approval policy - let context1 = EnvironmentContext::new( - Some(PathBuf::from("/repo")), - Some(AskForApproval::OnRequest), - Some(workspace_write_policy(vec!["/repo"], false)), - fake_shell(), - ); - let context2 = EnvironmentContext::new( - Some(PathBuf::from("/repo")), - Some(AskForApproval::Never), - Some(workspace_write_policy(vec!["/repo"], true)), - fake_shell(), - ); - assert!(!context1.equals_except_shell(&context2)); + fn equals_except_shell_compares_cwd() { + let context1 = EnvironmentContext::new(Some(PathBuf::from("/repo")), fake_shell()); + let context2 = EnvironmentContext::new(Some(PathBuf::from("/repo")), fake_shell()); + assert!(context1.equals_except_shell(&context2)); } #[test] - fn equals_except_shell_compares_sandbox_policy() { - let context1 = EnvironmentContext::new( - Some(PathBuf::from("/repo")), - Some(AskForApproval::OnRequest), - Some(SandboxPolicy::new_read_only_policy()), - fake_shell(), - ); - let context2 = EnvironmentContext::new( - Some(PathBuf::from("/repo")), - Some(AskForApproval::OnRequest), - Some(SandboxPolicy::new_workspace_write_policy()), - fake_shell(), - ); + fn equals_except_shell_ignores_sandbox_policy() { + let context1 = EnvironmentContext::new(Some(PathBuf::from("/repo")), fake_shell()); + let context2 = EnvironmentContext::new(Some(PathBuf::from("/repo")), fake_shell()); - assert!(!context1.equals_except_shell(&context2)); + assert!(context1.equals_except_shell(&context2)); } #[test] - fn equals_except_shell_compares_workspace_write_policy() { - let context1 = EnvironmentContext::new( - Some(PathBuf::from("/repo")), - Some(AskForApproval::OnRequest), - Some(workspace_write_policy(vec!["/repo", "/tmp", "/var"], false)), - fake_shell(), - ); - let context2 = EnvironmentContext::new( - Some(PathBuf::from("/repo")), - Some(AskForApproval::OnRequest), - Some(workspace_write_policy(vec!["/repo", "/tmp"], true)), - fake_shell(), - ); + fn equals_except_shell_compares_cwd_differences() { + let context1 = EnvironmentContext::new(Some(PathBuf::from("/repo1")), fake_shell()); + let context2 = EnvironmentContext::new(Some(PathBuf::from("/repo2")), fake_shell()); assert!(!context1.equals_except_shell(&context2)); } @@ -385,8 +186,6 @@ mod tests { fn equals_except_shell_ignores_shell() { let context1 = EnvironmentContext::new( Some(PathBuf::from("/repo")), - Some(AskForApproval::OnRequest), - Some(workspace_write_policy(vec!["/repo"], false)), Shell { shell_type: ShellType::Bash, shell_path: "/bin/bash".into(), @@ -395,8 +194,6 @@ mod tests { ); let context2 = EnvironmentContext::new( Some(PathBuf::from("/repo")), - Some(AskForApproval::OnRequest), - Some(workspace_write_policy(vec!["/repo"], false)), Shell { shell_type: ShellType::Zsh, shell_path: "/bin/zsh".into(), diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index d8880a9b3c..b057035bc9 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -5,9 +5,10 @@ use std::sync::Arc; use arc_swap::ArcSwap; -use crate::command_safety::is_dangerous_command::requires_initial_appoval; use crate::config_loader::ConfigLayerStack; use crate::config_loader::ConfigLayerStackOrdering; +use crate::is_dangerous_command::command_might_be_dangerous; +use crate::is_safe_command::is_known_safe_command; use codex_execpolicy::AmendError; use codex_execpolicy::Decision; use codex_execpolicy::Error as ExecPolicyRuleError; @@ -116,14 +117,15 @@ impl ExecPolicyManager { let exec_policy = self.current(); let commands = parse_shell_lc_plain_commands(command).unwrap_or_else(|| vec![command.to_vec()]); - let heuristics_fallback = |cmd: &[String]| { - if requires_initial_appoval(approval_policy, sandbox_policy, cmd, sandbox_permissions) { - Decision::Prompt - } else { - Decision::Allow - } + let exec_policy_fallback = |cmd: &[String]| { + render_decision_for_unmatched_command( + approval_policy, + sandbox_policy, + cmd, + sandbox_permissions, + ) }; - let evaluation = exec_policy.check_multiple(commands.iter(), &heuristics_fallback); + let evaluation = exec_policy.check_multiple(commands.iter(), &exec_policy_fallback); match evaluation.decision { Decision::Forbidden => ExecApprovalRequirement::Forbidden { @@ -242,6 +244,70 @@ pub async fn load_exec_policy(config_stack: &ConfigLayerStack) -> Result Decision { + if is_known_safe_command(command) { + return Decision::Allow; + } + + // On Windows, ReadOnly sandbox is not a real sandbox, so special-case it + // here. + let runtime_sandbox_provides_safety = + cfg!(windows) && matches!(sandbox_policy, SandboxPolicy::ReadOnly); + + // If the command is flagged as dangerous or we have no sandbox protection, + // we should never allow it to run without user approval. + // + // We prefer to prompt the user rather than outright forbid the command, + // but if the user has explicitly disabled prompts, we must + // forbid the command. + if command_might_be_dangerous(command) || runtime_sandbox_provides_safety { + return if matches!(approval_policy, AskForApproval::Never) { + Decision::Forbidden + } else { + Decision::Prompt + }; + } + + match approval_policy { + AskForApproval::Never | AskForApproval::OnFailure => { + // We allow the command to run, relying on the sandbox for + // protection. + Decision::Allow + } + AskForApproval::UnlessTrusted => { + // We already checked `is_known_safe_command(command)` and it + // returned false, so we must prompt. + Decision::Prompt + } + AskForApproval::OnRequest => { + match sandbox_policy { + SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => { + // The user has indicated we should "just run" commands + // in their unrestricted environment, so we do so since the + // command has not been flagged as dangerous. + Decision::Allow + } + SandboxPolicy::ReadOnly | SandboxPolicy::WorkspaceWrite { .. } => { + // In restricted sandboxes (ReadOnly/WorkspaceWrite), do not prompt for + // non‑escalated, non‑dangerous commands — let the sandbox enforce + // restrictions (e.g., block network/write) without a user prompt. + if sandbox_permissions.requires_escalated_permissions() { + Decision::Prompt + } else { + Decision::Allow + } + } + } + } + } +} + fn default_policy_path(codex_home: &Path) -> PathBuf { codex_home.join(RULES_DIR_NAME).join(DEFAULT_POLICY_FILE) } @@ -1051,4 +1117,108 @@ prefix_rule( } ); } + + fn vec_str(items: &[&str]) -> Vec { + items.iter().map(std::string::ToString::to_string).collect() + } + + /// Note this test behaves differently on Windows because it exercises an + /// `if cfg!(windows)` code path in render_decision_for_unmatched_command(). + #[tokio::test] + async fn verify_approval_requirement_for_unsafe_powershell_command() { + // `brew install powershell` to run this test on a Mac! + // Note `pwsh` is required to parse a PowerShell command to see if it + // is safe. + if which::which("pwsh").is_err() { + return; + } + + let policy = ExecPolicyManager::new(Arc::new(Policy::empty())); + let features = Features::with_defaults(); + let permissions = SandboxPermissions::UseDefault; + + // This command should not be run without user approval unless there is + // a proper sandbox in place to ensure safety. + let sneaky_command = vec_str(&["pwsh", "-Command", "echo hi @(calc)"]); + let expected_amendment = Some(ExecPolicyAmendment::new(vec_str(&[ + "pwsh", + "-Command", + "echo hi @(calc)", + ]))); + let (pwsh_approval_reason, expected_req) = if cfg!(windows) { + ( + r#"On Windows, SandboxPolicy::ReadOnly should be assumed to mean + that no sandbox is present, so anything that is not "provably + safe" should require approval."#, + ExecApprovalRequirement::NeedsApproval { + reason: None, + proposed_execpolicy_amendment: expected_amendment.clone(), + }, + ) + } else { + ( + "On non-Windows, rely on the read-only sandbox to prevent harm.", + ExecApprovalRequirement::Skip { + bypass_sandbox: false, + proposed_execpolicy_amendment: expected_amendment.clone(), + }, + ) + }; + assert_eq!( + expected_req, + policy + .create_exec_approval_requirement_for_command( + &features, + &sneaky_command, + AskForApproval::OnRequest, + &SandboxPolicy::ReadOnly, + permissions, + ) + .await, + "{pwsh_approval_reason}" + ); + + // This is flagged as a dangerous command on all platforms. + let dangerous_command = vec_str(&["rm", "-rf", "/important/data"]); + assert_eq!( + ExecApprovalRequirement::NeedsApproval { + reason: None, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec_str(&[ + "rm", + "-rf", + "/important/data", + ]))), + }, + policy + .create_exec_approval_requirement_for_command( + &features, + &dangerous_command, + AskForApproval::OnRequest, + &SandboxPolicy::ReadOnly, + permissions, + ) + .await, + r#"On all platforms, a forbidden command should require approval + (unless AskForApproval::Never is specified)."# + ); + + // A dangerous command should be forbidden if the user has specified + // AskForApproval::Never. + assert_eq!( + ExecApprovalRequirement::Forbidden { + reason: "`rm -rf /important/data` rejected: blocked by policy".to_string(), + }, + policy + .create_exec_approval_requirement_for_command( + &features, + &dangerous_command, + AskForApproval::Never, + &SandboxPolicy::ReadOnly, + permissions, + ) + .await, + r#"On all platforms, a forbidden command should require approval + (unless AskForApproval::Never is specified)."# + ); + } } diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 8c1c597ee7..5a9054eb98 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -8,6 +8,7 @@ use crate::config::ConfigToml; use crate::config::profile::ConfigProfile; use codex_otel::OtelManager; +use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use std::collections::BTreeMap; @@ -15,6 +16,7 @@ use std::collections::BTreeSet; mod legacy; pub(crate) use legacy::LegacyFeatureToggles; +pub(crate) use legacy::legacy_feature_keys; /// High-level lifecycle stage for a feature. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -96,6 +98,8 @@ pub enum Feature { EnableRequestCompression, /// Enable collab tools. Collab, + /// Steer feature flag - when enabled, Enter submits immediately instead of queuing. + Steer, } impl Feature { @@ -292,7 +296,7 @@ pub fn is_known_feature_key(key: &str) -> bool { } /// Deserializable features table for TOML. -#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)] pub struct FeaturesToml { #[serde(flatten)] pub entries: BTreeMap, @@ -420,4 +424,14 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Experimental, default_enabled: false, }, + FeatureSpec { + id: Feature::Steer, + key: "steer", + stage: Stage::Beta { + name: "Steer conversation", + menu_description: "Enter submits immediately; Tab queues messages when a task is running.", + announcement: "NEW! Try Steer mode: Enter submits immediately, Tab queues. Enable in /experimental!", + }, + default_enabled: false, + }, ]; diff --git a/codex-rs/core/src/features/legacy.rs b/codex-rs/core/src/features/legacy.rs index ed508ffb5a..2b5a9e7fe0 100644 --- a/codex-rs/core/src/features/legacy.rs +++ b/codex-rs/core/src/features/legacy.rs @@ -31,6 +31,10 @@ const ALIASES: &[Alias] = &[ }, ]; +pub(crate) fn legacy_feature_keys() -> impl Iterator { + ALIASES.iter().map(|alias| alias.legacy_key) +} + pub(crate) fn feature_for_key(key: &str) -> Option { ALIASES .iter() diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 1fb25ebc13..0b3d1c2781 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -57,6 +57,7 @@ pub use model_provider_info::DEFAULT_LMSTUDIO_PORT; pub use model_provider_info::DEFAULT_OLLAMA_PORT; pub use model_provider_info::LMSTUDIO_OSS_PROVIDER_ID; pub use model_provider_info::ModelProviderInfo; +pub use model_provider_info::OLLAMA_CHAT_PROVIDER_ID; pub use model_provider_info::OLLAMA_OSS_PROVIDER_ID; pub use model_provider_info::WireApi; pub use model_provider_info::built_in_model_providers; @@ -126,6 +127,7 @@ pub use codex_protocol::protocol; pub use codex_protocol::config_types as protocol_config_types; pub use client::ModelClient; +pub use client::ModelClientSession; pub use client_common::Prompt; pub use client_common::REVIEW_PROMPT; pub use client_common::ResponseEvent; diff --git a/codex-rs/core/src/mcp/mod.rs b/codex-rs/core/src/mcp/mod.rs index 677483646b..9e5446a743 100644 --- a/codex-rs/core/src/mcp/mod.rs +++ b/codex-rs/core/src/mcp/mod.rs @@ -47,7 +47,7 @@ pub async fn collect_mcp_snapshot(config: &Config) -> McpListToolsResponseEvent mcp_connection_manager .initialize( - config.mcp_servers.clone(), + &config.mcp_servers, config.mcp_oauth_credentials_store_mode, auth_status_entries.clone(), tx_event, diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index dcd1edf80c..6574437bd0 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -312,7 +312,7 @@ pub(crate) struct McpConnectionManager { impl McpConnectionManager { pub async fn initialize( &mut self, - mcp_servers: HashMap, + mcp_servers: &HashMap, store_mode: OAuthCredentialsStoreMode, auth_entries: HashMap, tx_event: Sender, @@ -325,6 +325,7 @@ impl McpConnectionManager { let mut clients = HashMap::new(); let mut join_set = JoinSet::new(); let elicitation_requests = ElicitationRequestManager::default(); + let mcp_servers = mcp_servers.clone(); for (server_name, cfg) in mcp_servers.into_iter().filter(|(_, cfg)| cfg.enabled) { let cancel_token = cancel_token.child_token(); let _ = emit_update( diff --git a/codex-rs/core/src/model_provider_info.rs b/codex-rs/core/src/model_provider_info.rs index 9617392237..b1422458ca 100644 --- a/codex-rs/core/src/model_provider_info.rs +++ b/codex-rs/core/src/model_provider_info.rs @@ -12,6 +12,7 @@ use codex_app_server_protocol::AuthMode; use http::HeaderMap; use http::header::HeaderName; use http::header::HeaderValue; +use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use std::collections::HashMap; @@ -36,19 +37,24 @@ const OPENAI_PROVIDER_NAME: &str = "OpenAI"; /// *Responses* API. The two protocols use different request/response shapes /// and *cannot* be auto-detected at runtime, therefore each provider entry /// must declare which one it expects. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "lowercase")] pub enum WireApi { /// The Responses API exposed by OpenAI at `/v1/responses`. Responses, + /// Experimental: Responses API over WebSocket transport. + #[serde(rename = "responses_websocket")] + ResponsesWebsocket, + /// Regular Chat Completions compatible with `/v1/chat/completions`. #[default] Chat, } /// Serializable representation of a provider definition. -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)] +#[schemars(deny_unknown_fields)] pub struct ModelProviderInfo { /// Friendly display name. pub name: String, @@ -156,6 +162,7 @@ impl ModelProviderInfo { query_params: self.query_params.clone(), wire: match self.wire_api { WireApi::Responses => ApiWireApi::Responses, + WireApi::ResponsesWebsocket => ApiWireApi::Responses, WireApi::Chat => ApiWireApi::Chat, }, headers, @@ -260,6 +267,7 @@ pub const DEFAULT_OLLAMA_PORT: u16 = 11434; pub const LMSTUDIO_OSS_PROVIDER_ID: &str = "lmstudio"; pub const OLLAMA_OSS_PROVIDER_ID: &str = "ollama"; +pub const OLLAMA_CHAT_PROVIDER_ID: &str = "ollama-chat"; /// Built-in default provider list. pub fn built_in_model_providers() -> HashMap { @@ -273,6 +281,10 @@ pub fn built_in_model_providers() -> HashMap { ("openai", P::create_openai_provider()), ( OLLAMA_OSS_PROVIDER_ID, + create_oss_provider(DEFAULT_OLLAMA_PORT, WireApi::Responses), + ), + ( + OLLAMA_CHAT_PROVIDER_ID, create_oss_provider(DEFAULT_OLLAMA_PORT, WireApi::Chat), ), ( diff --git a/codex-rs/core/src/models_manager/model_presets.rs b/codex-rs/core/src/models_manager/model_presets.rs index 080c44433b..003995c895 100644 --- a/codex-rs/core/src/models_manager/model_presets.rs +++ b/codex-rs/core/src/models_manager/model_presets.rs @@ -3,6 +3,7 @@ use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ModelUpgrade; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::ReasoningEffortPreset; +use indoc::indoc; use once_cell::sync::Lazy; pub const HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG: &str = "hide_gpt5_1_migration_prompt"; @@ -318,6 +319,16 @@ fn gpt_52_codex_upgrade() -> ModelUpgrade { "Codex is now powered by gpt-5.2-codex, our latest frontier agentic coding model. It is smarter and faster than its predecessors and capable of long-running project-scale work." .to_string(), ), + migration_markdown: Some( + indoc! {r#" + **Codex just got an upgrade. Introducing {model_to}.** + + Codex is now powered by gpt-5.2-codex, our latest frontier agentic coding model. It is smarter and faster than its predecessors and capable of long-running project-scale work. Learn more about {model_to} at https://openai.com/index/introducing-gpt-5-2-codex + + You can continue using {model_from} if you prefer. + "#} + .to_string(), + ), } } diff --git a/codex-rs/core/src/parse_command.rs b/codex-rs/core/src/parse_command.rs index 399513f5ae..0796b654ac 100644 --- a/codex-rs/core/src/parse_command.rs +++ b/codex-rs/core/src/parse_command.rs @@ -174,6 +174,24 @@ mod tests { ); } + #[test] + fn keeps_mutating_xargs_pipeline() { + let inner = r#"rg -l QkBindingController presentation/src/main/java | xargs perl -pi -e 's/QkBindingController/QkController/g'"#; + assert_parsed( + &vec_str(&["bash", "-lc", inner]), + vec![ + ParsedCommand::Search { + cmd: "rg -l QkBindingController presentation/src/main/java".to_string(), + query: Some("QkBindingController".to_string()), + path: Some("java".to_string()), + }, + ParsedCommand::Unknown { + cmd: "xargs perl -pi -e s/QkBindingController/QkController/g".to_string(), + }, + ], + ); + } + #[test] fn supports_cat() { let inner = "cat webview/README.md"; @@ -1404,8 +1422,9 @@ fn is_small_formatting_command(tokens: &[String]) -> bool { match cmd { // Always formatting; typically used in pipes. // `nl` is special-cased below to allow `nl ` to be treated as a read command. - "wc" | "tr" | "cut" | "sort" | "uniq" | "xargs" | "tee" | "column" | "awk" | "yes" - | "printf" => true, + "wc" | "tr" | "cut" | "sort" | "uniq" | "tee" | "column" | "awk" | "yes" => true, + "xargs" => !is_mutating_xargs_command(tokens), + "printf" => true, "head" => { // Treat as formatting when no explicit file operand is present. // Common forms: `head -n 40`, `head -c 100`. @@ -1465,6 +1484,54 @@ fn is_small_formatting_command(tokens: &[String]) -> bool { } } +fn is_mutating_xargs_command(tokens: &[String]) -> bool { + xargs_subcommand(tokens).is_some_and(xargs_is_mutating_subcommand) +} + +fn xargs_subcommand(tokens: &[String]) -> Option<&[String]> { + if tokens.first().map(String::as_str) != Some("xargs") { + return None; + } + let mut i = 1; + while i < tokens.len() { + let token = &tokens[i]; + if token == "--" { + return tokens.get(i + 1..).filter(|rest| !rest.is_empty()); + } + if !token.starts_with('-') { + return tokens.get(i..).filter(|rest| !rest.is_empty()); + } + let takes_value = matches!( + token.as_str(), + "-E" | "-e" | "-I" | "-L" | "-n" | "-P" | "-s" + ); + if takes_value && token.len() == 2 { + i += 2; + } else { + i += 1; + } + } + None +} + +fn xargs_is_mutating_subcommand(tokens: &[String]) -> bool { + let Some((head, tail)) = tokens.split_first() else { + return false; + }; + match head.as_str() { + "perl" | "ruby" => xargs_has_in_place_flag(tail), + "sed" => xargs_has_in_place_flag(tail) || tail.iter().any(|token| token == "--in-place"), + "rg" => tail.iter().any(|token| token == "--replace"), + _ => false, + } +} + +fn xargs_has_in_place_flag(tokens: &[String]) -> bool { + tokens.iter().any(|token| { + token == "-i" || token.starts_with("-i") || token == "-pi" || token.starts_with("-pi") + }) +} + fn drop_small_formatting_commands(mut commands: Vec>) -> Vec> { commands.retain(|tokens| !is_small_formatting_command(tokens)); commands diff --git a/codex-rs/core/src/rollout/truncation.rs b/codex-rs/core/src/rollout/truncation.rs index cd22240324..1f70be46f3 100644 --- a/codex-rs/core/src/rollout/truncation.rs +++ b/codex-rs/core/src/rollout/truncation.rs @@ -206,6 +206,7 @@ mod tests { RolloutItem::ResponseItem(items[0].clone()), RolloutItem::ResponseItem(items[1].clone()), RolloutItem::ResponseItem(items[2].clone()), + RolloutItem::ResponseItem(items[3].clone()), ]; assert_eq!( diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index 2e4395956a..cd1f1c0498 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -17,7 +17,7 @@ use tokio_util::sync::CancellationToken; pub(crate) struct SessionServices { pub(crate) mcp_connection_manager: Arc>, - pub(crate) mcp_startup_cancellation_token: CancellationToken, + pub(crate) mcp_startup_cancellation_token: Mutex, pub(crate) unified_exec_manager: UnifiedExecProcessManager, pub(crate) notifier: UserNotifier, pub(crate) rollout: Mutex>, diff --git a/codex-rs/core/src/state/turn.rs b/codex-rs/core/src/state/turn.rs index e2fff0554e..ddc9efafea 100644 --- a/codex-rs/core/src/state/turn.rs +++ b/codex-rs/core/src/state/turn.rs @@ -104,6 +104,10 @@ impl TurnState { ret } } + + pub(crate) fn has_pending_input(&self) -> bool { + !self.pending_input.is_empty() + } } impl ActiveTurn { diff --git a/codex-rs/core/src/tasks/regular.rs b/codex-rs/core/src/tasks/regular.rs index 69d2d960ac..f897d3ce87 100644 --- a/codex-rs/core/src/tasks/regular.rs +++ b/codex-rs/core/src/tasks/regular.rs @@ -29,8 +29,10 @@ impl SessionTask for RegularTask { cancellation_token: CancellationToken, ) -> Option { let sess = session.clone_session(); - let run_turn_span = - trace_span!(parent: sess.services.otel_manager.current_span(), "run_turn"); + let run_turn_span = trace_span!("run_turn"); + sess.services + .otel_manager + .apply_traceparent_parent(&run_turn_span); run_turn(sess, ctx, input, cancellation_token) .instrument(run_turn_span) .await diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 6124f67874..0a124f29cf 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -21,6 +21,7 @@ use crate::skills::SkillsManager; use codex_protocol::ThreadId; use codex_protocol::openai_models::ModelPreset; use codex_protocol::protocol::InitialHistory; +use codex_protocol::protocol::McpServerRefreshConfig; use codex_protocol::protocol::Op; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; @@ -30,6 +31,7 @@ use std::sync::Arc; #[cfg(any(test, feature = "test-support"))] use tempfile::TempDir; use tokio::sync::RwLock; +use tracing::warn; /// Represents a newly created Codex thread (formerly called a conversation), including the first event /// (which is [`EventMsg::SessionConfigured`]). @@ -144,6 +146,27 @@ impl ThreadManager { self.state.threads.read().await.keys().copied().collect() } + pub async fn refresh_mcp_servers(&self, refresh_config: McpServerRefreshConfig) { + let threads = self + .state + .threads + .read() + .await + .values() + .cloned() + .collect::>(); + for thread in threads { + if let Err(err) = thread + .submit(Op::RefreshMcpServers { + config: refresh_config.clone(), + }) + .await + { + warn!("failed to request MCP server refresh: {err}"); + } + } + } + pub async fn get_thread(&self, thread_id: ThreadId) -> CodexResult> { self.state.get_thread(thread_id).await } @@ -226,6 +249,7 @@ impl ThreadManager { } impl ThreadManagerState { + /// 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 @@ -234,6 +258,7 @@ impl ThreadManagerState { .ok_or_else(|| CodexErr::ThreadNotFound(thread_id)) } + /// Send an operation to a thread by ID. pub(crate) async fn send_op(&self, thread_id: ThreadId, op: Op) -> CodexResult { let thread = self.get_thread(thread_id).await?; #[cfg(any(test, feature = "test-support"))] @@ -245,7 +270,12 @@ impl ThreadManagerState { thread.submit(op).await } - #[allow(dead_code)] // Used by upcoming multi-agent tooling. + /// Remove a thread from the manager by ID, returning it when present. + pub(crate) async fn remove_thread(&self, thread_id: &ThreadId) -> Option> { + self.threads.write().await.remove(thread_id) + } + + /// Spawn a new thread with no history using a provided config. pub(crate) async fn spawn_new_thread( &self, config: Config, @@ -260,6 +290,7 @@ impl ThreadManagerState { .await } + /// Spawn a new thread with optional history and register it with the manager. pub(crate) async fn spawn_thread( &self, config: Config, @@ -427,6 +458,7 @@ mod tests { RolloutItem::ResponseItem(items[0].clone()), RolloutItem::ResponseItem(items[1].clone()), RolloutItem::ResponseItem(items[2].clone()), + RolloutItem::ResponseItem(items[3].clone()), ]; assert_eq!( diff --git a/codex-rs/core/src/tools/handlers/collab.rs b/codex-rs/core/src/tools/handlers/collab.rs index 0f74ef658a..ef25d712b0 100644 --- a/codex-rs/core/src/tools/handlers/collab.rs +++ b/codex-rs/core/src/tools/handlers/collab.rs @@ -74,6 +74,11 @@ mod spawn { message: String, } + #[derive(Debug, Serialize)] + struct SpawnAgentResult { + agent_id: String, + } + pub async fn handle( session: Arc, turn: Arc, @@ -82,7 +87,7 @@ mod spawn { let args: SpawnAgentArgs = parse_arguments(&arguments)?; if args.message.trim().is_empty() { return Err(FunctionCallError::RespondToModel( - "Empty message can't be send to an agent".to_string(), + "Empty message can't be sent to an agent".to_string(), )); } let config = build_agent_spawn_config(turn.as_ref())?; @@ -91,10 +96,17 @@ mod spawn { .agent_control .spawn_agent(config, args.message, true) .await - .map_err(|err| FunctionCallError::Fatal(err.to_string()))?; + .map_err(collab_spawn_error)?; + + let content = serde_json::to_string(&SpawnAgentResult { + agent_id: result.to_string(), + }) + .map_err(|err| { + FunctionCallError::Fatal(format!("failed to serialize spawn_agent result: {err}")) + })?; Ok(ToolOutput::Function { - content: format!("agent_id: {result}"), + content, success: Some(true), content_items: None, }) @@ -112,6 +124,11 @@ mod send_input { message: String, } + #[derive(Debug, Serialize)] + struct SendInputResult { + submission_id: String, + } + pub async fn handle( session: Arc, arguments: String, @@ -120,20 +137,20 @@ mod send_input { let agent_id = agent_id(&args.id)?; if args.message.trim().is_empty() { return Err(FunctionCallError::RespondToModel( - "Empty message can't be send to an agent".to_string(), + "Empty message can't be sent to an agent".to_string(), )); } - let content = session + let agent_id_for_err = agent_id; + let submission_id = session .services .agent_control .send_prompt(agent_id, args.message) .await - .map_err(|err| match err { - CodexErr::ThreadNotFound(id) => { - FunctionCallError::RespondToModel(format!("agent with id {id} not found")) - } - err => FunctionCallError::Fatal(err.to_string()), - })?; + .map_err(|err| collab_agent_error(agent_id_for_err, err))?; + + let content = serde_json::to_string(&SendInputResult { submission_id }).map_err(|err| { + FunctionCallError::Fatal(format!("failed to serialize send_input result: {err}")) + })?; Ok(ToolOutput::Function { content, @@ -182,17 +199,13 @@ mod wait { ms => ms.min(MAX_WAIT_TIMEOUT_MS), }; + let agent_id_for_err = agent_id; let mut status_rx = session .services .agent_control .subscribe_status(agent_id) .await - .map_err(|err| match err { - CodexErr::ThreadNotFound(id) => { - FunctionCallError::RespondToModel(format!("agent with id {id} not found")) - } - err => FunctionCallError::Fatal(err.to_string()), - })?; + .map_err(|err| collab_agent_error(agent_id_for_err, err))?; // Get last known status. let mut status = status_rx.borrow_and_update().clone(); @@ -230,9 +243,11 @@ mod wait { FunctionCallError::Fatal(format!("failed to serialize wait result: {err}")) })?; + let success = !result.timed_out && !matches!(result.status, AgentStatus::Errored(_)); + Ok(ToolOutput::Function { content, - success: Some(!result.timed_out), + success: Some(success), content_items: None, }) } @@ -254,31 +269,23 @@ pub mod close_agent { ) -> Result { let args: CloseAgentArgs = parse_arguments(&arguments)?; let agent_id = agent_id(&args.id)?; + let agent_id_for_err = agent_id; let mut status_rx = session .services .agent_control .subscribe_status(agent_id) .await - .map_err(|err| match err { - CodexErr::ThreadNotFound(id) => { - FunctionCallError::RespondToModel(format!("agent with id {id} not found")) - } - err => FunctionCallError::Fatal(err.to_string()), - })?; + .map_err(|err| collab_agent_error(agent_id_for_err, err))?; let status = status_rx.borrow_and_update().clone(); if !matches!(status, AgentStatus::Shutdown) { + let agent_id_for_err = agent_id; let _ = session .services .agent_control .shutdown_agent(agent_id) .await - .map_err(|err| match err { - CodexErr::ThreadNotFound(id) => { - FunctionCallError::RespondToModel(format!("agent with id {id} not found")) - } - err => FunctionCallError::Fatal(err.to_string()), - })?; + .map_err(|err| collab_agent_error(agent_id_for_err, err))?; } let content = serde_json::to_string(&CloseAgentResult { status }).map_err(|err| { @@ -298,6 +305,30 @@ fn agent_id(id: &str) -> Result { .map_err(|e| FunctionCallError::RespondToModel(format!("invalid agent id {id}: {e:?}"))) } +fn collab_spawn_error(err: CodexErr) -> FunctionCallError { + match err { + CodexErr::UnsupportedOperation(_) => { + FunctionCallError::RespondToModel("collab manager unavailable".to_string()) + } + err => FunctionCallError::RespondToModel(format!("collab spawn failed: {err}")), + } +} + +fn collab_agent_error(agent_id: ThreadId, err: CodexErr) -> FunctionCallError { + match err { + CodexErr::ThreadNotFound(id) => { + FunctionCallError::RespondToModel(format!("agent with id {id} not found")) + } + CodexErr::InternalAgentDied => { + FunctionCallError::RespondToModel(format!("agent with id {agent_id} is closed")) + } + CodexErr::UnsupportedOperation(_) => { + FunctionCallError::RespondToModel("collab manager unavailable".to_string()) + } + err => FunctionCallError::RespondToModel(format!("collab tool failed: {err}")), + } +} + fn build_agent_spawn_config(turn: &TurnContext) -> Result { let base_config = turn.client.config(); let mut config = (*base_config).clone(); @@ -433,7 +464,7 @@ mod tests { assert_eq!( err, FunctionCallError::RespondToModel( - "Empty message can't be send to an agent".to_string() + "Empty message can't be sent to an agent".to_string() ) ); } @@ -452,7 +483,7 @@ mod tests { }; assert_eq!( err, - FunctionCallError::Fatal("unsupported operation: thread manager dropped".to_string()) + FunctionCallError::RespondToModel("collab manager unavailable".to_string()) ); } @@ -471,7 +502,7 @@ mod tests { assert_eq!( err, FunctionCallError::RespondToModel( - "Empty message can't be send to an agent".to_string() + "Empty message can't be sent to an agent".to_string() ) ); } @@ -664,6 +695,9 @@ mod tests { .iter() .any(|(id, op)| *id == agent_id && matches!(op, Op::Shutdown)); assert_eq!(submitted_shutdown, true); + + let status_after = manager.agent_control().get_status(agent_id).await; + assert_eq!(status_after, AgentStatus::NotFound); } #[tokio::test] diff --git a/codex-rs/core/src/user_instructions.rs b/codex-rs/core/src/user_instructions.rs index 22b5ad7bbe..9c563c29c5 100644 --- a/codex-rs/core/src/user_instructions.rs +++ b/codex-rs/core/src/user_instructions.rs @@ -75,34 +75,6 @@ impl From for ResponseItem { } } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename = "developer_instructions", rename_all = "snake_case")] -pub(crate) struct DeveloperInstructions { - text: String, -} - -impl DeveloperInstructions { - pub fn new>(text: T) -> Self { - Self { text: text.into() } - } - - pub fn into_text(self) -> String { - self.text - } -} - -impl From for ResponseItem { - fn from(di: DeveloperInstructions) -> Self { - ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: di.into_text(), - }], - } - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/core/tests/chat_completions_payload.rs b/codex-rs/core/tests/chat_completions_payload.rs index 54d13367a5..23b50823f9 100644 --- a/codex-rs/core/tests/chat_completions_payload.rs +++ b/codex-rs/core/tests/chat_completions_payload.rs @@ -88,7 +88,7 @@ async fn run_request(input: Vec) -> Value { SessionSource::Exec, ); - let client = ModelClient::new( + let mut client_session = ModelClient::new( Arc::clone(&config), None, model_info, @@ -98,12 +98,13 @@ async fn run_request(input: Vec) -> Value { summary, conversation_id, SessionSource::Exec, - ); + ) + .new_session(); let mut prompt = Prompt::default(); prompt.input = input; - let mut stream = match client.stream(&prompt).await { + let mut stream = match client_session.stream(&prompt).await { Ok(s) => s, Err(e) => panic!("stream chat failed: {e}"), }; diff --git a/codex-rs/core/tests/chat_completions_sse.rs b/codex-rs/core/tests/chat_completions_sse.rs index 65b1f229b3..f6d7eb24fe 100644 --- a/codex-rs/core/tests/chat_completions_sse.rs +++ b/codex-rs/core/tests/chat_completions_sse.rs @@ -89,7 +89,7 @@ async fn run_stream_with_bytes(sse_body: &[u8]) -> Vec { SessionSource::Exec, ); - let client = ModelClient::new( + let mut client = ModelClient::new( Arc::clone(&config), None, model_info, @@ -99,7 +99,8 @@ async fn run_stream_with_bytes(sse_body: &[u8]) -> Vec { summary, conversation_id, SessionSource::Exec, - ); + ) + .new_session(); let mut prompt = Prompt::default(); prompt.input = vec![ResponseItem::Message { diff --git a/codex-rs/core/tests/common/Cargo.toml b/codex-rs/core/tests/common/Cargo.toml index c61a095686..8e9f53943a 100644 --- a/codex-rs/core/tests/common/Cargo.toml +++ b/codex-rs/core/tests/common/Cargo.toml @@ -15,11 +15,13 @@ codex-core = { workspace = true, features = ["test-support"] } codex-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-cargo-bin = { workspace = true } +futures = { workspace = true } notify = { workspace = true } regex-lite = { workspace = true } serde_json = { workspace = true } tempfile = { workspace = true } -tokio = { workspace = true, features = ["time"] } +tokio = { workspace = true, features = ["net", "time"] } +tokio-tungstenite = { workspace = true } walkdir = { workspace = true } wiremock = { workspace = true } shlex = { workspace = true } diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index 710d03fc7f..8f698ed285 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -1,3 +1,4 @@ +use std::collections::VecDeque; use std::sync::Arc; use std::sync::Mutex; use std::time::Duration; @@ -5,7 +6,12 @@ use std::time::Duration; use anyhow::Result; use base64::Engine; use codex_protocol::openai_models::ModelsResponse; +use futures::SinkExt; +use futures::StreamExt; use serde_json::Value; +use tokio::net::TcpListener; +use tokio::sync::oneshot; +use tokio_tungstenite::tungstenite::Message; use wiremock::BodyPrintLimit; use wiremock::Match; use wiremock::Mock; @@ -199,6 +205,47 @@ impl ResponsesRequest { } } +#[derive(Debug, Clone)] +pub struct WebSocketRequest { + body: Value, +} + +impl WebSocketRequest { + pub fn body_json(&self) -> Value { + self.body.clone() + } +} + +pub struct WebSocketTestServer { + uri: String, + connections: Arc>>>, + shutdown: oneshot::Sender<()>, + task: tokio::task::JoinHandle<()>, +} + +impl WebSocketTestServer { + pub fn uri(&self) -> &str { + &self.uri + } + + pub fn connections(&self) -> Vec> { + self.connections.lock().unwrap().clone() + } + + pub fn single_connection(&self) -> Vec { + let connections = self.connections.lock().unwrap(); + if connections.len() != 1 { + panic!("expected 1 connection, got {}", connections.len()); + } + connections.first().cloned().unwrap_or_default() + } + + pub async fn shutdown(self) { + let _ = self.shutdown.send(()); + let _ = self.task.await; + } +} + #[derive(Debug, Clone)] pub struct ModelsMock { requests: Arc>>, @@ -272,6 +319,15 @@ pub fn ev_completed(id: &str) -> Value { }) } +pub fn ev_done() -> Value { + serde_json::json!({ + "type": "response.done", + "response": { + "usage": {"input_tokens":0,"input_tokens_details":null,"output_tokens":0,"output_tokens_details":null,"total_tokens":0} + } + }) +} + /// Convenience: SSE event for a created response with a specific id. pub fn ev_response_created(id: &str) -> Value { serde_json::json!({ @@ -724,6 +780,91 @@ pub async fn start_mock_server() -> MockServer { server } +/// Starts a lightweight WebSocket server for `/v1/responses` tests. +/// +/// Each connection consumes a queue of request/event sequences. For each +/// request message, the server records the payload and streams the matching +/// events as WebSocket text frames before moving to the next request. +pub async fn start_websocket_server(connections: Vec>>) -> WebSocketTestServer { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind websocket server"); + let addr = listener.local_addr().expect("websocket server address"); + let uri = format!("ws://{addr}"); + let connections_log = Arc::new(Mutex::new(Vec::new())); + let requests = Arc::clone(&connections_log); + let connections = Arc::new(Mutex::new(VecDeque::from(connections))); + let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); + + let task = tokio::spawn(async move { + loop { + let accept_res = tokio::select! { + _ = &mut shutdown_rx => return, + accept_res = listener.accept() => accept_res, + }; + let (stream, _) = match accept_res { + Ok(value) => value, + Err(_) => return, + }; + let mut ws_stream = match tokio_tungstenite::accept_async(stream).await { + Ok(ws) => ws, + Err(_) => continue, + }; + + let connection_requests = { + let mut pending = connections.lock().unwrap(); + pending.pop_front() + }; + + let Some(connection_requests) = connection_requests else { + let _ = ws_stream.close(None).await; + continue; + }; + + let mut connection_log = Vec::new(); + for request_events in connection_requests { + let Some(Ok(message)) = ws_stream.next().await else { + break; + }; + if let Some(body) = parse_ws_request_body(message) { + connection_log.push(WebSocketRequest { body }); + } + + for event in &request_events { + let Ok(payload) = serde_json::to_string(event) else { + continue; + }; + if ws_stream.send(Message::Text(payload)).await.is_err() { + break; + } + } + } + + requests.lock().unwrap().push(connection_log); + let _ = ws_stream.close(None).await; + + if connections.lock().unwrap().is_empty() { + return; + } + } + }); + + WebSocketTestServer { + uri, + connections: connections_log, + shutdown: shutdown_tx, + task, + } +} + +fn parse_ws_request_body(message: Message) -> Option { + match message { + Message::Text(text) => serde_json::from_str(&text).ok(), + Message::Binary(bytes) => serde_json::from_slice(&bytes).ok(), + _ => None, + } +} + #[derive(Clone)] pub struct FunctionCallResponseMocks { pub function_call: ResponseMock, diff --git a/codex-rs/core/tests/common/streaming_sse.rs b/codex-rs/core/tests/common/streaming_sse.rs index 4f1b3673b0..db34a2c172 100644 --- a/codex-rs/core/tests/common/streaming_sse.rs +++ b/codex-rs/core/tests/common/streaming_sse.rs @@ -19,6 +19,7 @@ pub struct StreamingSseChunk { /// Minimal streaming SSE server for tests that need gated per-chunk delivery. pub struct StreamingSseServer { uri: String, + requests: Arc>>>, shutdown: oneshot::Sender<()>, task: tokio::task::JoinHandle<()>, } @@ -28,6 +29,10 @@ impl StreamingSseServer { &self.uri } + pub async fn requests(&self) -> Vec> { + self.requests.lock().await.clone() + } + pub async fn shutdown(self) { let _ = self.shutdown.send(()); let _ = self.task.await; @@ -61,6 +66,8 @@ pub async fn start_streaming_sse_server( responses: VecDeque::from(responses), completions: VecDeque::from(completion_senders), })); + let requests = Arc::new(TokioMutex::new(Vec::new())); + let requests_for_task = Arc::clone(&requests); let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); let task = tokio::spawn(async move { @@ -70,6 +77,7 @@ pub async fn start_streaming_sse_server( accept_res = listener.accept() => { let (mut stream, _) = accept_res.expect("accept streaming SSE connection"); let state = Arc::clone(&state); + let requests = Arc::clone(&requests_for_task); tokio::spawn(async move { let (request, body_prefix) = read_http_request(&mut stream).await; let Some((method, path)) = parse_request_line(&request) else { @@ -78,7 +86,7 @@ pub async fn start_streaming_sse_server( }; if method == "GET" && path == "/v1/models" { - if drain_request_body(&mut stream, &request, body_prefix) + if read_request_body(&mut stream, &request, body_prefix) .await .is_err() { @@ -95,13 +103,16 @@ pub async fn start_streaming_sse_server( } if method == "POST" && path == "/v1/responses" { - if drain_request_body(&mut stream, &request, body_prefix) + let body = match read_request_body(&mut stream, &request, body_prefix) .await - .is_err() { - let _ = write_http_response(&mut stream, 400, "bad request", "text/plain").await; - return; - } + Ok(body) => body, + Err(_) => { + let _ = write_http_response(&mut stream, 400, "bad request", "text/plain").await; + return; + } + }; + requests.lock().await.push(body); let Some((chunks, completion)) = take_next_stream(&state).await else { let _ = write_http_response(&mut stream, 500, "no responses queued", "text/plain").await; return; @@ -137,6 +148,7 @@ pub async fn start_streaming_sse_server( ( StreamingSseServer { uri, + requests, shutdown: shutdown_tx, task, }, @@ -202,13 +214,13 @@ fn content_length(headers: &str) -> Option { }) } -async fn drain_request_body( +async fn read_request_body( stream: &mut tokio::net::TcpStream, headers: &str, mut body_prefix: Vec, -) -> std::io::Result<()> { +) -> std::io::Result> { let Some(content_len) = content_length(headers) else { - return Ok(()); + return Ok(body_prefix); }; if body_prefix.len() > content_len { @@ -217,12 +229,13 @@ async fn drain_request_body( let remaining = content_len.saturating_sub(body_prefix.len()); if remaining == 0 { - return Ok(()); + return Ok(body_prefix); } let mut rest = vec![0u8; remaining]; stream.read_exact(&mut rest).await?; - Ok(()) + body_prefix.extend_from_slice(&rest); + Ok(body_prefix) } async fn write_sse_headers(stream: &mut tokio::net::TcpStream) -> std::io::Result<()> { diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 7aaa096c39..6d59cd4df3 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -8,6 +8,7 @@ use codex_core::CodexAuth; use codex_core::CodexThread; use codex_core::ModelProviderInfo; use codex_core::ThreadManager; +use codex_core::WireApi; use codex_core::built_in_model_providers; use codex_core::config::Config; use codex_core::features::Feature; @@ -23,6 +24,7 @@ use tempfile::TempDir; use wiremock::MockServer; use crate::load_default_config_for_test; +use crate::responses::WebSocketTestServer; use crate::responses::start_mock_server; use crate::streaming_sse::StreamingSseServer; use crate::wait_for_event; @@ -101,6 +103,21 @@ impl TestCodexBuilder { .await } + pub async fn build_with_websocket_server( + &mut self, + server: &WebSocketTestServer, + ) -> anyhow::Result { + let base_url = format!("{}/v1", server.uri()); + let home = Arc::new(TempDir::new()?); + let base_url_clone = base_url.clone(); + self.config_mutators.push(Box::new(move |config| { + config.model_provider.base_url = Some(base_url_clone); + config.model_provider.wire_api = WireApi::ResponsesWebsocket; + })); + self.build_with_home_and_base_url(base_url, home, None) + .await + } + pub async fn resume( &mut self, server: &wiremock::MockServer, diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs index dbbf0d57df..8be6e36340 100644 --- a/codex-rs/core/tests/responses_headers.rs +++ b/codex-rs/core/tests/responses_headers.rs @@ -81,7 +81,7 @@ async fn responses_stream_includes_subagent_header_on_review() { session_source.clone(), ); - let client = ModelClient::new( + let mut client_session = ModelClient::new( Arc::clone(&config), None, model_info, @@ -91,7 +91,8 @@ async fn responses_stream_includes_subagent_header_on_review() { summary, conversation_id, session_source, - ); + ) + .new_session(); let mut prompt = Prompt::default(); prompt.input = vec![ResponseItem::Message { @@ -102,7 +103,7 @@ async fn responses_stream_includes_subagent_header_on_review() { }], }]; - let mut stream = client.stream(&prompt).await.expect("stream failed"); + let mut stream = client_session.stream(&prompt).await.expect("stream failed"); while let Some(event) = stream.next().await { if matches!(event, Ok(ResponseEvent::Completed { .. })) { break; @@ -176,7 +177,7 @@ async fn responses_stream_includes_subagent_header_on_other() { session_source.clone(), ); - let client = ModelClient::new( + let mut client_session = ModelClient::new( Arc::clone(&config), None, model_info, @@ -186,7 +187,8 @@ async fn responses_stream_includes_subagent_header_on_other() { summary, conversation_id, session_source, - ); + ) + .new_session(); let mut prompt = Prompt::default(); prompt.input = vec![ResponseItem::Message { @@ -197,7 +199,7 @@ async fn responses_stream_includes_subagent_header_on_other() { }], }]; - let mut stream = client.stream(&prompt).await.expect("stream failed"); + let mut stream = client_session.stream(&prompt).await.expect("stream failed"); while let Some(event) = stream.next().await { if matches!(event, Ok(ResponseEvent::Completed { .. })) { break; @@ -269,7 +271,7 @@ async fn responses_respects_model_info_overrides_from_config() { session_source.clone(), ); - let client = ModelClient::new( + let mut client = ModelClient::new( Arc::clone(&config), None, model_info, @@ -279,7 +281,8 @@ async fn responses_respects_model_info_overrides_from_config() { summary, conversation_id, session_source, - ); + ) + .new_session(); let mut prompt = Prompt::default(); prompt.input = vec![ResponseItem::Message { diff --git a/codex-rs/core/tests/suite/agent_websocket.rs b/codex-rs/core/tests/suite/agent_websocket.rs new file mode 100644 index 0000000000..940995fb8d --- /dev/null +++ b/codex-rs/core/tests/suite/agent_websocket.rs @@ -0,0 +1,69 @@ +use anyhow::Result; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_done; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::ev_shell_command_call; +use core_test_support::responses::start_websocket_server; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::test_codex; +use pretty_assertions::assert_eq; +use serde_json::Value; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn websocket_test_codex_shell_chain() -> Result<()> { + skip_if_no_network!(Ok(())); + + let call_id = "shell-command-call"; + let server = start_websocket_server(vec![vec![ + vec![ + ev_response_created("resp-1"), + ev_shell_command_call(call_id, "echo websocket"), + ev_done(), + ], + vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ], + ]]) + .await; + + let mut builder = test_codex(); + + let test = builder.build_with_websocket_server(&server).await?; + test.submit_turn("run the echo command").await?; + + let connection = server.single_connection(); + assert_eq!(connection.len(), 2); + + let first = connection + .first() + .expect("missing first request") + .body_json(); + let second = connection + .get(1) + .expect("missing second request") + .body_json(); + + assert_eq!(first["type"].as_str(), Some("response.create")); + assert_eq!(second["type"].as_str(), Some("response.append")); + + let append_items = second + .get("input") + .and_then(Value::as_array) + .expect("response.append input array"); + assert!(!append_items.is_empty()); + + let output_item = append_items + .iter() + .find(|item| item.get("type").and_then(Value::as_str) == Some("function_call_output")) + .expect("function_call_output in append"); + assert_eq!( + output_item.get("call_id").and_then(Value::as_str), + Some(call_id) + ); + + server.shutdown().await; + Ok(()) +} diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 06846c46ab..ecb8dcbbf2 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -284,7 +284,7 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { let expected_initial_json = json!([]); assert_eq!(initial_json, expected_initial_json); - // 2) Submit new input; the request body must include the prior item followed by the new user input. + // 2) Submit new input; the request body must include the prior items, then initial context, then new user input. codex .submit(Op::UserInput { items: vec![UserInput::Text { @@ -298,24 +298,55 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { let request = resp_mock.single_request(); let request_body = request.body_json(); - let expected_input = json!([ - { - "type": "message", - "role": "user", - "content": [{ "type": "input_text", "text": "resumed user message" }] - }, - { - "type": "message", - "role": "assistant", - "content": [{ "type": "output_text", "text": "resumed assistant message" }] - }, - { - "type": "message", - "role": "user", - "content": [{ "type": "input_text", "text": "hello" }] - } - ]); - assert_eq!(request_body["input"], expected_input); + let input = request_body["input"].as_array().expect("input array"); + let messages: Vec<(String, String)> = input + .iter() + .filter_map(|item| { + let role = item.get("role")?.as_str()?; + let text = item + .get("content")? + .as_array()? + .first()? + .get("text")? + .as_str()?; + Some((role.to_string(), text.to_string())) + }) + .collect(); + let pos_prior_user = messages + .iter() + .position(|(role, text)| role == "user" && text == "resumed user message") + .expect("prior user message"); + let pos_prior_assistant = messages + .iter() + .position(|(role, text)| role == "assistant" && text == "resumed assistant message") + .expect("prior assistant message"); + let pos_permissions = messages + .iter() + .position(|(role, text)| role == "developer" && text.contains("`approval_policy`")) + .expect("permissions message"); + let pos_user_instructions = messages + .iter() + .position(|(role, text)| { + role == "user" + && text.contains("be nice") + && (text.starts_with("# AGENTS.md instructions for ") + || text.starts_with("")) + }) + .expect("user instructions"); + let pos_environment = messages + .iter() + .position(|(role, text)| role == "user" && text.contains("")) + .expect("environment context"); + let pos_new_user = messages + .iter() + .position(|(role, text)| role == "user" && text == "hello") + .expect("new user message"); + + assert!(pos_prior_user < pos_prior_assistant); + assert!(pos_prior_assistant < pos_permissions); + assert!(pos_permissions < pos_user_instructions); + assert!(pos_user_instructions < pos_environment); + assert!(pos_environment < pos_new_user); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -344,7 +375,7 @@ async fn includes_conversation_id_and_model_headers_in_request() { ); let NewThread { thread: codex, - thread_id: conversation_id, + thread_id: session_id, session_configured: _, .. } = thread_manager @@ -366,15 +397,13 @@ async fn includes_conversation_id_and_model_headers_in_request() { let request = resp_mock.single_request(); assert_eq!(request.path(), "/v1/responses"); - let request_conversation_id = request - .header("conversation_id") - .expect("conversation_id header"); + let request_session_id = request.header("session_id").expect("session_id header"); let request_authorization = request .header("authorization") .expect("authorization header"); let request_originator = request.header("originator").expect("originator header"); - assert_eq!(request_conversation_id, conversation_id.to_string()); + assert_eq!(request_session_id, session_id.to_string()); assert_eq!(request_originator, "codex_cli_rs"); assert_eq!(request_authorization, "Bearer Test API Key"); } @@ -455,7 +484,7 @@ async fn chatgpt_auth_sends_correct_request() { ); let NewThread { thread: codex, - thread_id: conversation_id, + thread_id, session_configured: _, .. } = thread_manager @@ -477,9 +506,6 @@ async fn chatgpt_auth_sends_correct_request() { let request = resp_mock.single_request(); assert_eq!(request.path(), "/api/codex/responses"); - let request_conversation_id = request - .header("conversation_id") - .expect("conversation_id header"); let request_authorization = request .header("authorization") .expect("authorization header"); @@ -489,7 +515,9 @@ async fn chatgpt_auth_sends_correct_request() { .expect("chatgpt-account-id header"); let request_body = request.body_json(); - assert_eq!(request_conversation_id, conversation_id.to_string()); + let session_id = request.header("session_id").expect("session_id header"); + assert_eq!(session_id, thread_id.to_string()); + assert_eq!(request_originator, "codex_cli_rs"); assert_eq!(request_authorization, "Bearer Access Token"); assert_eq!(request_chatgpt_account_id, "account_id"); @@ -618,17 +646,26 @@ async fn includes_user_instructions_message_in_request() { .unwrap() .contains("be nice") ); - assert_message_role(&request_body["input"][0], "user"); - assert_message_starts_with(&request_body["input"][0], "# AGENTS.md instructions for "); - assert_message_ends_with(&request_body["input"][0], ""); - let ui_text = request_body["input"][0]["content"][0]["text"] + assert_message_role(&request_body["input"][0], "developer"); + let permissions_text = request_body["input"][0]["content"][0]["text"] + .as_str() + .expect("invalid permissions message content"); + assert!( + permissions_text.contains("`sandbox_mode`"), + "expected permissions message to mention sandbox_mode, got {permissions_text:?}" + ); + + assert_message_role(&request_body["input"][1], "user"); + assert_message_starts_with(&request_body["input"][1], "# AGENTS.md instructions for "); + assert_message_ends_with(&request_body["input"][1], ""); + let ui_text = request_body["input"][1]["content"][0]["text"] .as_str() .expect("invalid message content"); assert!(ui_text.contains("")); assert!(ui_text.contains("be nice")); - assert_message_role(&request_body["input"][1], "user"); - assert_message_starts_with(&request_body["input"][1], ""); - assert_message_ends_with(&request_body["input"][1], ""); + assert_message_role(&request_body["input"][2], "user"); + assert_message_starts_with(&request_body["input"][2], ""); + assert_message_ends_with(&request_body["input"][2], ""); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -682,8 +719,10 @@ async fn skills_append_to_instructions() { let request = resp_mock.single_request(); let request_body = request.body_json(); - assert_message_role(&request_body["input"][0], "user"); - let instructions_text = request_body["input"][0]["content"][0]["text"] + assert_message_role(&request_body["input"][0], "developer"); + + assert_message_role(&request_body["input"][1], "user"); + let instructions_text = request_body["input"][1]["content"][0]["text"] .as_str() .expect("instructions text"); assert!( @@ -1049,6 +1088,10 @@ async fn includes_developer_instructions_message_in_request() { let request = resp_mock.single_request(); let request_body = request.body_json(); + let permissions_text = request_body["input"][0]["content"][0]["text"] + .as_str() + .expect("invalid permissions message content"); + assert!( !request_body["instructions"] .as_str() @@ -1056,18 +1099,24 @@ async fn includes_developer_instructions_message_in_request() { .contains("be nice") ); assert_message_role(&request_body["input"][0], "developer"); - assert_message_equals(&request_body["input"][0], "be useful"); - assert_message_role(&request_body["input"][1], "user"); - assert_message_starts_with(&request_body["input"][1], "# AGENTS.md instructions for "); - assert_message_ends_with(&request_body["input"][1], ""); - let ui_text = request_body["input"][1]["content"][0]["text"] + assert!( + permissions_text.contains("`sandbox_mode`"), + "expected permissions message to mention sandbox_mode, got {permissions_text:?}" + ); + + assert_message_role(&request_body["input"][1], "developer"); + assert_message_equals(&request_body["input"][1], "be useful"); + assert_message_role(&request_body["input"][2], "user"); + assert_message_starts_with(&request_body["input"][2], "# AGENTS.md instructions for "); + assert_message_ends_with(&request_body["input"][2], ""); + let ui_text = request_body["input"][2]["content"][0]["text"] .as_str() .expect("invalid message content"); assert!(ui_text.contains("")); assert!(ui_text.contains("be nice")); - assert_message_role(&request_body["input"][2], "user"); - assert_message_starts_with(&request_body["input"][2], ""); - assert_message_ends_with(&request_body["input"][2], ""); + assert_message_role(&request_body["input"][3], "user"); + assert_message_starts_with(&request_body["input"][3], ""); + assert_message_ends_with(&request_body["input"][3], ""); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -1122,7 +1171,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { SessionSource::Exec, ); - let client = ModelClient::new( + let mut client = ModelClient::new( Arc::clone(&config), None, model_info, @@ -1132,7 +1181,8 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { summary, conversation_id, SessionSource::Exec, - ); + ) + .new_session(); let mut prompt = Prompt::default(); prompt.input.push(ResponseItem::Reasoning { diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs new file mode 100644 index 0000000000..9d39fb2402 --- /dev/null +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -0,0 +1,213 @@ +#![allow(clippy::expect_used, clippy::unwrap_used)] +use codex_core::AuthManager; +use codex_core::CodexAuth; +use codex_core::ContentItem; +use codex_core::ModelClient; +use codex_core::ModelClientSession; +use codex_core::ModelProviderInfo; +use codex_core::Prompt; +use codex_core::ResponseEvent; +use codex_core::ResponseItem; +use codex_core::WireApi; +use codex_core::models_manager::manager::ModelsManager; +use codex_core::protocol::SessionSource; +use codex_otel::OtelManager; +use codex_protocol::ThreadId; +use codex_protocol::config_types::ReasoningSummary; +use core_test_support::load_default_config_for_test; +use core_test_support::responses::WebSocketTestServer; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::start_websocket_server; +use core_test_support::skip_if_no_network; +use futures::StreamExt; +use pretty_assertions::assert_eq; +use std::sync::Arc; +use tempfile::TempDir; + +const MODEL: &str = "gpt-5.2-codex"; + +struct WebsocketTestHarness { + _codex_home: TempDir, + client: ModelClient, +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_websocket_streams_request() { + skip_if_no_network!(); + + let server = start_websocket_server(vec![vec![vec![ + ev_response_created("resp-1"), + ev_completed("resp-1"), + ]]]) + .await; + + let harness = websocket_harness(&server).await; + let mut session = harness.client.new_session(); + let prompt = prompt_with_input(vec![message_item("hello")]); + + stream_until_complete(&mut session, &prompt).await; + + let connection = server.single_connection(); + assert_eq!(connection.len(), 1); + let body = connection.first().expect("missing request").body_json(); + + assert_eq!(body["type"].as_str(), Some("response.create")); + assert_eq!(body["model"].as_str(), Some(MODEL)); + assert_eq!(body["stream"], serde_json::Value::Bool(true)); + assert_eq!(body["input"].as_array().map(Vec::len), Some(1)); + + server.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_websocket_appends_on_prefix() { + skip_if_no_network!(); + + let server = start_websocket_server(vec![vec![ + vec![ev_response_created("resp-1"), ev_completed("resp-1")], + vec![ev_response_created("resp-2"), ev_completed("resp-2")], + ]]) + .await; + + let harness = websocket_harness(&server).await; + let mut session = harness.client.new_session(); + let prompt_one = prompt_with_input(vec![message_item("hello")]); + let prompt_two = prompt_with_input(vec![message_item("hello"), message_item("second")]); + + stream_until_complete(&mut session, &prompt_one).await; + stream_until_complete(&mut session, &prompt_two).await; + + let connection = server.single_connection(); + assert_eq!(connection.len(), 2); + let first = connection.first().expect("missing request").body_json(); + let second = connection.get(1).expect("missing request").body_json(); + + assert_eq!(first["type"].as_str(), Some("response.create")); + assert_eq!(first["model"].as_str(), Some(MODEL)); + assert_eq!(first["stream"], serde_json::Value::Bool(true)); + assert_eq!(first["input"].as_array().map(Vec::len), Some(1)); + let expected_append = serde_json::json!({ + "type": "response.append", + "input": serde_json::to_value(&prompt_two.input[1..]).expect("serialize append items"), + }); + assert_eq!(second, expected_append); + + server.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_websocket_creates_on_non_prefix() { + skip_if_no_network!(); + + let server = start_websocket_server(vec![vec![ + vec![ev_response_created("resp-1"), ev_completed("resp-1")], + vec![ev_response_created("resp-2"), ev_completed("resp-2")], + ]]) + .await; + + let harness = websocket_harness(&server).await; + let mut session = harness.client.new_session(); + let prompt_one = prompt_with_input(vec![message_item("hello")]); + let prompt_two = prompt_with_input(vec![message_item("different")]); + + stream_until_complete(&mut session, &prompt_one).await; + stream_until_complete(&mut session, &prompt_two).await; + + let connection = server.single_connection(); + assert_eq!(connection.len(), 2); + let second = connection.get(1).expect("missing request").body_json(); + + assert_eq!(second["type"].as_str(), Some("response.create")); + assert_eq!(second["model"].as_str(), Some(MODEL)); + assert_eq!(second["stream"], serde_json::Value::Bool(true)); + assert_eq!( + second["input"], + serde_json::to_value(&prompt_two.input).unwrap() + ); + + server.shutdown().await; +} + +fn message_item(text: &str) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "user".into(), + content: vec![ContentItem::InputText { text: text.into() }], + } +} + +fn prompt_with_input(input: Vec) -> Prompt { + let mut prompt = Prompt::default(); + prompt.input = input; + prompt +} + +fn websocket_provider(server: &WebSocketTestServer) -> ModelProviderInfo { + ModelProviderInfo { + name: "mock-ws".into(), + base_url: Some(format!("{}/v1", server.uri())), + env_key: None, + env_key_instructions: None, + experimental_bearer_token: None, + wire_api: WireApi::ResponsesWebsocket, + query_params: None, + http_headers: None, + env_http_headers: None, + request_max_retries: Some(0), + stream_max_retries: Some(0), + stream_idle_timeout_ms: Some(5_000), + requires_openai_auth: false, + } +} + +async fn websocket_harness(server: &WebSocketTestServer) -> WebsocketTestHarness { + let provider = websocket_provider(server); + let codex_home = TempDir::new().unwrap(); + let mut config = load_default_config_for_test(&codex_home).await; + config.model = Some(MODEL.to_string()); + let config = Arc::new(config); + let model_info = ModelsManager::construct_model_info_offline(MODEL, &config); + let conversation_id = ThreadId::new(); + let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); + let otel_manager = OtelManager::new( + conversation_id, + MODEL, + model_info.slug.as_str(), + None, + Some("test@test.com".to_string()), + auth_manager.get_auth_mode(), + false, + "test".to_string(), + SessionSource::Exec, + ); + let client = ModelClient::new( + Arc::clone(&config), + None, + model_info, + otel_manager, + provider.clone(), + None, + ReasoningSummary::Auto, + conversation_id, + SessionSource::Exec, + ); + + WebsocketTestHarness { + _codex_home: codex_home, + client, + } +} + +async fn stream_until_complete(session: &mut ModelClientSession, prompt: &Prompt) { + let mut stream = session + .stream(prompt) + .await + .expect("websocket stream failed"); + + while let Some(event) = stream.next().await { + if matches!(event, Ok(ResponseEvent::Completed { .. })) { + break; + } + } +} diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index ee58399708..e3c8e0b7c2 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -604,8 +604,14 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { .and_then(|item| item.get("text")) .and_then(|text| text.as_str()); - // Ignore the cached UI prefix (project docs + skills) since it is not relevant to - // compaction behavior and can change as bundled skills evolve. + // Ignore cached prefix messages (project docs + permissions) since they are not + // relevant to compaction behavior and can change as bundled prompts evolve. + let role = value.get("role").and_then(|role| role.as_str()); + if role == Some("developer") + && text.is_some_and(|text| text.contains("`sandbox_mode`")) + { + return false; + } !text.is_some_and(|text| text.starts_with("# AGENTS.md instructions for ")) }) .cloned() @@ -1726,9 +1732,11 @@ async fn manual_compact_twice_preserves_latest_user_messages() { .into_iter() .collect::>(); - // System prompt + // Permissions developer message final_output.pop_front(); - // Developer instructions + // User instructions (project docs/skills) + final_output.pop_front(); + // Environment context final_output.pop_front(); let _ = final_output diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index ad2e0e65ad..a8de9e5c15 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -216,11 +216,12 @@ async fn compact_resume_and_fork_preserve_model_history_view() { .as_str() .unwrap_or_default() .to_string(); - let user_instructions = requests[0]["input"][0]["content"][0]["text"] + let permissions_message = requests[0]["input"][0].clone(); + let user_instructions = requests[0]["input"][1]["content"][0]["text"] .as_str() .unwrap_or_default() .to_string(); - let environment_context = requests[0]["input"][1]["content"][0]["text"] + let environment_context = requests[0]["input"][2]["content"][0]["text"] .as_str() .unwrap_or_default() .to_string(); @@ -241,6 +242,7 @@ async fn compact_resume_and_fork_preserve_model_history_view() { "model": expected_model, "instructions": prompt, "input": [ + permissions_message, { "type": "message", "role": "user", @@ -290,6 +292,7 @@ async fn compact_resume_and_fork_preserve_model_history_view() { "model": expected_model, "instructions": prompt, "input": [ + permissions_message, { "type": "message", "role": "user", @@ -359,6 +362,7 @@ async fn compact_resume_and_fork_preserve_model_history_view() { "model": expected_model, "instructions": prompt, "input": [ + permissions_message, { "type": "message", "role": "user", @@ -419,6 +423,7 @@ async fn compact_resume_and_fork_preserve_model_history_view() { "model": expected_model, "instructions": prompt, "input": [ + permissions_message, { "type": "message", "role": "user", @@ -470,6 +475,27 @@ async fn compact_resume_and_fork_preserve_model_history_view() { } ] }, + permissions_message, + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": user_instructions + } + ] + }, + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": environment_context + } + ] + }, { "type": "message", "role": "user", @@ -499,6 +525,7 @@ async fn compact_resume_and_fork_preserve_model_history_view() { "model": expected_model, "instructions": prompt, "input": [ + permissions_message, { "type": "message", "role": "user", @@ -550,6 +577,48 @@ async fn compact_resume_and_fork_preserve_model_history_view() { } ] }, + permissions_message, + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": user_instructions + } + ] + }, + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": environment_context + } + ] + }, + permissions_message, + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": user_instructions + } + ] + }, + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": environment_context + } + ] + }, { "type": "message", "role": "user", @@ -664,11 +733,12 @@ async fn compact_resume_after_second_compaction_preserves_history() { .as_str() .unwrap_or_default() .to_string(); - let user_instructions = requests[0]["input"][0]["content"][0]["text"] + let permissions_message = requests[0]["input"][0].clone(); + let user_instructions = requests[0]["input"][1]["content"][0]["text"] .as_str() .unwrap_or_default() .to_string(); - let environment_instructions = requests[0]["input"][1]["content"][0]["text"] + let environment_instructions = requests[0]["input"][2]["content"][0]["text"] .as_str() .unwrap_or_default() .to_string(); @@ -682,6 +752,7 @@ async fn compact_resume_after_second_compaction_preserves_history() { { "instructions": prompt, "input": [ + permissions_message, { "type": "message", "role": "user", @@ -723,6 +794,27 @@ async fn compact_resume_after_second_compaction_preserves_history() { } ] }, + permissions_message, + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": user_instructions + } + ] + }, + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": environment_instructions + } + ] + }, { "type": "message", "role": "user", diff --git a/codex-rs/core/tests/suite/fork_thread.rs b/codex-rs/core/tests/suite/fork_thread.rs index 98f1dafd5b..e2e6535752 100644 --- a/codex-rs/core/tests/suite/fork_thread.rs +++ b/codex-rs/core/tests/suite/fork_thread.rs @@ -138,8 +138,9 @@ async fn fork_thread_twice_drops_to_first_message() { // GetHistory on fork1 flushed; the file is ready. let fork1_items = read_items(&fork1_path); + assert!(fork1_items.len() > expected_after_first.len()); pretty_assertions::assert_eq!( - serde_json::to_value(&fork1_items).unwrap(), + serde_json::to_value(&fork1_items[..expected_after_first.len()]).unwrap(), serde_json::to_value(&expected_after_first).unwrap() ); @@ -162,8 +163,9 @@ async fn fork_thread_twice_drops_to_first_message() { .unwrap_or(0); let expected_after_second: Vec = fork1_items[..cut_last_on_fork1].to_vec(); let fork2_items = read_items(&fork2_path); + assert!(fork2_items.len() > expected_after_second.len()); pretty_assertions::assert_eq!( - serde_json::to_value(&fork2_items).unwrap(), + serde_json::to_value(&fork2_items[..expected_after_second.len()]).unwrap(), serde_json::to_value(&expected_after_second).unwrap() ); } diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs new file mode 100644 index 0000000000..401c809129 --- /dev/null +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -0,0 +1,239 @@ +use anyhow::Context; +use codex_core::protocol::AskForApproval; +use codex_core::protocol::EventMsg; +use codex_core::protocol::Op; +use codex_core::protocol::RolloutItem; +use codex_core::protocol::RolloutLine; +use codex_core::protocol::SandboxPolicy; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; +use codex_protocol::user_input::UserInput; +use core_test_support::responses; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use image::ImageBuffer; +use image::Rgba; +use pretty_assertions::assert_eq; +use std::path::Path; +use std::time::Duration; + +fn find_user_message_with_image(text: &str) -> Option { + for line in text.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let rollout: RolloutLine = match serde_json::from_str(trimmed) { + Ok(rollout) => rollout, + Err(_) => continue, + }; + if let RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. }) = + &rollout.item + && role == "user" + && content + .iter() + .any(|span| matches!(span, ContentItem::InputImage { .. })) + && let RolloutItem::ResponseItem(item) = rollout.item.clone() + { + return Some(item); + } + } + None +} + +fn extract_image_url(item: &ResponseItem) -> Option { + match item { + ResponseItem::Message { content, .. } => content.iter().find_map(|span| match span { + ContentItem::InputImage { image_url } => Some(image_url.clone()), + _ => None, + }), + _ => None, + } +} + +async fn read_rollout_text(path: &Path) -> anyhow::Result { + for _ in 0..50 { + if path.exists() + && let Ok(text) = std::fs::read_to_string(path) + && !text.trim().is_empty() + { + return Ok(text); + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + std::fs::read_to_string(path) + .with_context(|| format!("read rollout file at {}", path.display())) +} + +fn write_test_png(path: &Path, color: [u8; 4]) -> anyhow::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let image = ImageBuffer::from_pixel(2, 2, Rgba(color)); + image.save(path)?; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + + let TestCodex { + codex, + cwd, + session_configured, + home: _home, + .. + } = test_codex().build(&server).await?; + + let rel_path = "images/paste.png"; + let abs_path = cwd.path().join(rel_path); + write_test_png(&abs_path, [12, 34, 56, 255])?; + + let response = sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-1"), + ]); + responses::mount_sse_once(&server, response).await; + + let session_model = session_configured.model.clone(); + + codex + .submit(Op::UserTurn { + items: vec![ + UserInput::LocalImage { + path: abs_path.clone(), + }, + UserInput::Text { + text: "pasted image".to_string(), + }, + ], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: session_model, + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + + wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + codex.submit(Op::Shutdown).await?; + wait_for_event(&codex, |event| matches!(event, EventMsg::ShutdownComplete)).await; + + let rollout_path = codex.rollout_path(); + let rollout_text = read_rollout_text(&rollout_path).await?; + let actual = find_user_message_with_image(&rollout_text) + .expect("expected user message with input image in rollout"); + + let image_url = extract_image_url(&actual).expect("expected image url in rollout"); + let expected = ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ + ContentItem::InputText { + text: codex_protocol::models::local_image_open_tag_text(1), + }, + ContentItem::InputImage { image_url }, + ContentItem::InputText { + text: codex_protocol::models::image_close_tag_text(), + }, + ContentItem::InputText { + text: "pasted image".to_string(), + }, + ], + }; + + assert_eq!(actual, expected); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + + let TestCodex { + codex, + cwd, + session_configured, + home: _home, + .. + } = test_codex().build(&server).await?; + + let image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII=".to_string(); + + let response = sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-1"), + ]); + responses::mount_sse_once(&server, response).await; + + let session_model = session_configured.model.clone(); + + codex + .submit(Op::UserTurn { + items: vec![ + UserInput::Image { + image_url: image_url.clone(), + }, + UserInput::Text { + text: "dropped image".to_string(), + }, + ], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: session_model, + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + + wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + codex.submit(Op::Shutdown).await?; + wait_for_event(&codex, |event| matches!(event, EventMsg::ShutdownComplete)).await; + + let rollout_path = codex.rollout_path(); + let rollout_text = read_rollout_text(&rollout_path).await?; + let actual = find_user_message_with_image(&rollout_text) + .expect("expected user message with input image in rollout"); + + let image_url = extract_image_url(&actual).expect("expected image url in rollout"); + let expected = ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ + ContentItem::InputText { + text: codex_protocol::models::image_open_tag_text(), + }, + ContentItem::InputImage { image_url }, + ContentItem::InputText { + text: codex_protocol::models::image_close_tag_text(), + }, + ContentItem::InputText { + text: "dropped image".to_string(), + }, + ], + }; + + assert_eq!(actual, expected); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/list_models.rs b/codex-rs/core/tests/suite/list_models.rs index b81ebcb72d..182fabefe8 100644 --- a/codex-rs/core/tests/suite/list_models.rs +++ b/codex-rs/core/tests/suite/list_models.rs @@ -6,6 +6,7 @@ use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::ReasoningEffortPreset; use core_test_support::load_default_config_for_test; +use indoc::indoc; use pretty_assertions::assert_eq; use tempfile::tempdir; @@ -410,6 +411,16 @@ fn gpt52_codex_upgrade() -> codex_protocol::openai_models::ModelUpgrade { "Codex is now powered by gpt-5.2-codex, our latest frontier agentic coding model. It is smarter and faster than its predecessors and capable of long-running project-scale work." .to_string(), ), + migration_markdown: Some( + indoc! {r#" + **Codex just got an upgrade. Introducing {model_to}.** + + Codex is now powered by gpt-5.2-codex, our latest frontier agentic coding model. It is smarter and faster than its predecessors and capable of long-running project-scale work. Learn more about {model_to} at https://openai.com/index/introducing-gpt-5-2-codex + + You can continue using {model_from} if you prefer. + "#} + .to_string(), + ), } } diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 44093778d3..66fcb5cdbe 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -15,12 +15,14 @@ pub static CODEX_ALIASES_TEMP_DIR: TempDir = unsafe { #[cfg(not(target_os = "windows"))] mod abort_tasks; +mod agent_websocket; mod apply_patch_cli; #[cfg(not(target_os = "windows"))] mod approvals; mod auth_refresh; mod cli_stream; mod client; +mod client_websockets; mod codex_delegate; mod compact; mod compact_remote; @@ -31,6 +33,7 @@ mod exec_policy; mod fork_thread; mod grep_files; mod hierarchical_agents; +mod image_rollout; mod items; mod json_result; mod list_dir; @@ -41,6 +44,8 @@ mod model_overrides; mod model_tools; mod models_etag_responses; mod otel; +mod pending_input; +mod permissions_messages; mod prompt_caching; mod quota_exceeded; mod read_file; diff --git a/codex-rs/core/tests/suite/pending_input.rs b/codex-rs/core/tests/suite/pending_input.rs new file mode 100644 index 0000000000..3fbf79fa0c --- /dev/null +++ b/codex-rs/core/tests/suite/pending_input.rs @@ -0,0 +1,143 @@ +use codex_core::protocol::EventMsg; +use codex_core::protocol::Op; +use codex_protocol::user_input::UserInput; +use core_test_support::responses; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_message_item_added; +use core_test_support::responses::ev_output_text_delta; +use core_test_support::responses::ev_response_created; +use core_test_support::streaming_sse::StreamingSseChunk; +use core_test_support::streaming_sse::start_streaming_sse_server; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use pretty_assertions::assert_eq; +use serde_json::Value; +use tokio::sync::oneshot; + +fn ev_message_item_done(id: &str, text: &str) -> Value { + serde_json::json!({ + "type": "response.output_item.done", + "item": { + "type": "message", + "role": "assistant", + "id": id, + "content": [{"type": "output_text", "text": text}] + } + }) +} + +fn sse_event(event: Value) -> String { + responses::sse(vec![event]) +} + +fn message_input_texts(body: &Value, role: &str) -> Vec { + body.get("input") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter(|item| item.get("type").and_then(Value::as_str) == Some("message")) + .filter(|item| item.get("role").and_then(Value::as_str) == Some(role)) + .filter_map(|item| item.get("content").and_then(Value::as_array)) + .flatten() + .filter(|span| span.get("type").and_then(Value::as_str) == Some("input_text")) + .filter_map(|span| span.get("text").and_then(Value::as_str).map(str::to_owned)) + .collect() +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn injected_user_input_triggers_follow_up_request_with_deltas() { + let (gate_completed_tx, gate_completed_rx) = oneshot::channel(); + + let first_chunks = vec![ + StreamingSseChunk { + gate: None, + body: sse_event(ev_response_created("resp-1")), + }, + StreamingSseChunk { + gate: None, + body: sse_event(ev_message_item_added("msg-1", "")), + }, + StreamingSseChunk { + gate: None, + body: sse_event(ev_output_text_delta("first ")), + }, + StreamingSseChunk { + gate: None, + body: sse_event(ev_output_text_delta("turn")), + }, + StreamingSseChunk { + gate: None, + body: sse_event(ev_message_item_done("msg-1", "first turn")), + }, + StreamingSseChunk { + gate: Some(gate_completed_rx), + body: sse_event(ev_completed("resp-1")), + }, + ]; + + let second_chunks = vec![ + StreamingSseChunk { + gate: None, + body: sse_event(ev_response_created("resp-2")), + }, + StreamingSseChunk { + gate: None, + body: sse_event(ev_completed("resp-2")), + }, + ]; + + let (server, _completions) = + start_streaming_sse_server(vec![first_chunks, second_chunks]).await; + + let codex = test_codex() + .with_model("gpt-5.1") + .build_with_streaming_server(&server) + .await + .unwrap() + .codex; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "first prompt".into(), + }], + final_output_json_schema: None, + }) + .await + .unwrap(); + + wait_for_event(&codex, |event| { + matches!(event, EventMsg::AgentMessageContentDelta(_)) + }) + .await; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "second prompt".into(), + }], + final_output_json_schema: None, + }) + .await + .unwrap(); + + let _ = gate_completed_tx.send(()); + + wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + + let requests = server.requests().await; + assert_eq!(requests.len(), 2); + + let first_body: Value = serde_json::from_slice(&requests[0]).expect("parse first request"); + let second_body: Value = serde_json::from_slice(&requests[1]).expect("parse second request"); + + let first_texts = message_input_texts(&first_body, "user"); + assert!(first_texts.iter().any(|text| text == "first prompt")); + assert!(!first_texts.iter().any(|text| text == "second prompt")); + + let second_texts = message_input_texts(&second_body, "user"); + assert!(second_texts.iter().any(|text| text == "first prompt")); + assert!(second_texts.iter().any(|text| text == "second prompt")); + + server.shutdown().await; +} diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs new file mode 100644 index 0000000000..3f8a9b4903 --- /dev/null +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -0,0 +1,448 @@ +use anyhow::Result; +use codex_core::config::Constrained; +use codex_core::protocol::AskForApproval; +use codex_core::protocol::EventMsg; +use codex_core::protocol::Op; +use codex_core::protocol::SandboxPolicy; +use codex_protocol::user_input::UserInput; +use codex_utils_absolute_path::AbsolutePathBuf; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_once; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use pretty_assertions::assert_eq; +use std::collections::HashSet; +use tempfile::TempDir; + +fn permissions_texts(input: &[serde_json::Value]) -> Vec { + input + .iter() + .filter_map(|item| { + let role = item.get("role")?.as_str()?; + if role != "developer" { + return None; + } + let text = item + .get("content")? + .as_array()? + .first()? + .get("text")? + .as_str()?; + if text.contains("`approval_policy`") { + Some(text.to_string()) + } else { + None + } + }) + .collect() +} + +fn sse_completed(id: &str) -> String { + sse(vec![ev_response_created(id), ev_completed(id)]) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn permissions_message_sent_once_on_start() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let req = mount_sse_once(&server, sse_completed("resp-1")).await; + + let mut builder = test_codex().with_config(move |config| { + config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); + }); + let test = builder.build(&server).await?; + + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "hello".into(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let request = req.single_request(); + let body = request.body_json(); + let input = body["input"].as_array().expect("input array"); + let permissions = permissions_texts(input); + assert_eq!(permissions.len(), 1); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn permissions_message_added_on_override_change() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let req1 = mount_sse_once(&server, sse_completed("resp-1")).await; + let req2 = mount_sse_once(&server, sse_completed("resp-2")).await; + + let mut builder = test_codex().with_config(move |config| { + config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); + }); + let test = builder.build(&server).await?; + + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "hello 1".into(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + test.codex + .submit(Op::OverrideTurnContext { + cwd: None, + approval_policy: Some(AskForApproval::Never), + sandbox_policy: None, + model: None, + effort: None, + summary: None, + }) + .await?; + + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "hello 2".into(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let body1 = req1.single_request().body_json(); + let body2 = req2.single_request().body_json(); + let input1 = body1["input"].as_array().expect("input array"); + let input2 = body2["input"].as_array().expect("input array"); + let permissions_1 = permissions_texts(input1); + let permissions_2 = permissions_texts(input2); + + assert_eq!(permissions_1.len(), 1); + assert_eq!(permissions_2.len(), 2); + let unique = permissions_2.into_iter().collect::>(); + assert_eq!(unique.len(), 2); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn permissions_message_not_added_when_no_change() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let req1 = mount_sse_once(&server, sse_completed("resp-1")).await; + let req2 = mount_sse_once(&server, sse_completed("resp-2")).await; + + let mut builder = test_codex().with_config(move |config| { + config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); + }); + let test = builder.build(&server).await?; + + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "hello 1".into(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "hello 2".into(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let body1 = req1.single_request().body_json(); + let body2 = req2.single_request().body_json(); + let input1 = body1["input"].as_array().expect("input array"); + let input2 = body2["input"].as_array().expect("input array"); + let permissions_1 = permissions_texts(input1); + let permissions_2 = permissions_texts(input2); + + assert_eq!(permissions_1.len(), 1); + assert_eq!(permissions_2.len(), 1); + assert_eq!(permissions_1, permissions_2); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn resume_replays_permissions_messages() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let _req1 = mount_sse_once(&server, sse_completed("resp-1")).await; + let _req2 = mount_sse_once(&server, sse_completed("resp-2")).await; + let req3 = mount_sse_once(&server, sse_completed("resp-3")).await; + + let mut builder = test_codex().with_config(|config| { + config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); + }); + let initial = builder.build(&server).await?; + let rollout_path = initial.session_configured.rollout_path.clone(); + let home = initial.home.clone(); + + initial + .codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "hello 1".into(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + initial + .codex + .submit(Op::OverrideTurnContext { + cwd: None, + approval_policy: Some(AskForApproval::Never), + sandbox_policy: None, + model: None, + effort: None, + summary: None, + }) + .await?; + + initial + .codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "hello 2".into(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let resumed = builder.resume(&server, home, rollout_path).await?; + resumed + .codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "after resume".into(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let body3 = req3.single_request().body_json(); + let input = body3["input"].as_array().expect("input array"); + let permissions = permissions_texts(input); + assert_eq!(permissions.len(), 3); + let unique = permissions.into_iter().collect::>(); + assert_eq!(unique.len(), 2); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn resume_and_fork_append_permissions_messages() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let _req1 = mount_sse_once(&server, sse_completed("resp-1")).await; + let req2 = mount_sse_once(&server, sse_completed("resp-2")).await; + let req3 = mount_sse_once(&server, sse_completed("resp-3")).await; + let req4 = mount_sse_once(&server, sse_completed("resp-4")).await; + + let mut builder = test_codex().with_config(|config| { + config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); + }); + let initial = builder.build(&server).await?; + let rollout_path = initial.session_configured.rollout_path.clone(); + let home = initial.home.clone(); + + initial + .codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "hello 1".into(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + initial + .codex + .submit(Op::OverrideTurnContext { + cwd: None, + approval_policy: Some(AskForApproval::Never), + sandbox_policy: None, + model: None, + effort: None, + summary: None, + }) + .await?; + + initial + .codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "hello 2".into(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let body2 = req2.single_request().body_json(); + let input2 = body2["input"].as_array().expect("input array"); + let permissions_base = permissions_texts(input2); + assert_eq!(permissions_base.len(), 2); + + builder = builder.with_config(|config| { + config.approval_policy = Constrained::allow_any(AskForApproval::UnlessTrusted); + }); + let resumed = builder.resume(&server, home, rollout_path.clone()).await?; + resumed + .codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "after resume".into(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let body3 = req3.single_request().body_json(); + let input3 = body3["input"].as_array().expect("input array"); + let permissions_resume = permissions_texts(input3); + assert_eq!(permissions_resume.len(), permissions_base.len() + 1); + assert_eq!( + &permissions_resume[..permissions_base.len()], + permissions_base.as_slice() + ); + assert!(!permissions_base.contains(permissions_resume.last().expect("new permissions"))); + + let mut fork_config = initial.config.clone(); + fork_config.approval_policy = Constrained::allow_any(AskForApproval::UnlessTrusted); + let forked = initial + .thread_manager + .fork_thread(usize::MAX, fork_config, rollout_path) + .await?; + forked + .thread + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "after fork".into(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&forked.thread, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let body4 = req4.single_request().body_json(); + let input4 = body4["input"].as_array().expect("input array"); + let permissions_fork = permissions_texts(input4); + assert_eq!(permissions_fork.len(), permissions_base.len() + 2); + assert_eq!( + &permissions_fork[..permissions_base.len()], + permissions_base.as_slice() + ); + let new_permissions = &permissions_fork[permissions_base.len()..]; + assert_eq!(new_permissions.len(), 2); + assert_eq!(new_permissions[0], new_permissions[1]); + assert!(!permissions_base.contains(&new_permissions[0])); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn permissions_message_includes_writable_roots() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let req = mount_sse_once(&server, sse_completed("resp-1")).await; + let writable = TempDir::new()?; + let writable_root = AbsolutePathBuf::try_from(writable.path())?; + let sandbox_policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![writable_root], + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }; + + let mut builder = test_codex().with_config(move |config| { + config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); + config.sandbox_policy = Constrained::allow_any(sandbox_policy); + }); + let test = builder.build(&server).await?; + + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "hello".into(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let body = req.single_request().body_json(); + let input = body["input"].as_array().expect("input array"); + let permissions = permissions_texts(input); + let sandbox_text = "Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `workspace-write`: The sandbox permits reading files, and editing files in `cwd` and `writable_roots`. Editing files in other directories requires approval. Network access is restricted."; + let approval_text = " Approvals are your mechanism to get user consent to run shell commands without the sandbox. `approval_policy` is `on-request`: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. If the completing the task requires escalated permissions, Do not let these settings or the sandbox deter you from attempting to accomplish the user's task.\n\nHere are scenarios where you'll need to request approval:\n- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /var)\n- You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files.\n- You are running sandboxed and need to run a command that requires network access (e.g. installing packages)\n- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `sandbox_permissions` and `justification` parameters - do not message the user before requesting approval for the command.\n- You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for.\n\nWhen requesting approval to execute a command that will require escalated privileges:\n - Provide the `sandbox_permissions` parameter with the value `\"require_escalated\"`\n - Include a short, 1 sentence explanation for why you need escalated permissions in the justification parameter"; + // Normalize paths by removing trailing slashes to match AbsolutePathBuf behavior + let normalize_path = + |p: &std::path::Path| -> String { p.to_string_lossy().trim_end_matches('/').to_string() }; + let mut roots = vec![ + normalize_path(writable.path()), + normalize_path(test.config.cwd.as_path()), + ]; + if cfg!(unix) && std::path::Path::new("/tmp").is_dir() { + roots.push("/tmp".to_string()); + } + if let Some(tmpdir) = std::env::var_os("TMPDIR") { + let tmpdir_path = std::path::PathBuf::from(&tmpdir); + if tmpdir_path.is_absolute() && !tmpdir.is_empty() { + roots.push(normalize_path(&tmpdir_path)); + } + } + let roots_text = if roots.len() == 1 { + format!(" The writable root is `{}`.", roots[0]) + } else { + format!( + " The writable roots are {}.", + roots + .iter() + .map(|root| format!("`{root}`")) + .collect::>() + .join(", ") + ) + }; + let expected = format!( + "{sandbox_text}{approval_text}{roots_text}" + ); + // Normalize line endings to handle Windows vs Unix differences + let normalize_line_endings = |s: &str| s.replace("\r\n", "\n"); + let expected_normalized = normalize_line_endings(&expected); + let actual_normalized: Vec = permissions + .iter() + .map(|s| normalize_line_endings(s)) + .collect(); + assert_eq!(actual_normalized, vec![expected_normalized]); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 160b623c6b..79c9d6e19c 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -36,9 +36,6 @@ fn default_env_context_str(cwd: &str, shell: &Shell) -> String { format!( r#" {cwd} - on-request - read-only - restricted {shell_name} "# ) @@ -252,9 +249,13 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests let body1 = req1.single_request().body_json(); let input1 = body1["input"].as_array().expect("input array"); - assert_eq!(input1.len(), 3, "expected cached prefix + env + user msg"); + assert_eq!( + input1.len(), + 4, + "expected permissions + cached prefix + env + user msg" + ); - let ui_text = input1[0]["content"][0]["text"] + let ui_text = input1[1]["content"][0]["text"] .as_str() .expect("ui message text"); assert!( @@ -266,11 +267,11 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests let cwd_str = config.cwd.to_string_lossy(); let expected_env_text = default_env_context_str(&cwd_str, &shell); assert_eq!( - input1[1], + input1[2], text_user_input(expected_env_text), "expected environment context after UI message" ); - assert_eq!(input1[2], text_user_input("hello 1".to_string())); + assert_eq!(input1[3], text_user_input("hello 1".to_string())); let body2 = req2.single_request().body_json(); let input2 = body2["input"].as_array().expect("input array"); @@ -312,16 +313,17 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; let writable = TempDir::new().unwrap(); + let new_policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![writable.path().try_into().unwrap()], + network_access: true, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }; codex .submit(Op::OverrideTurnContext { cwd: None, approval_policy: Some(AskForApproval::Never), - sandbox_policy: Some(SandboxPolicy::WorkspaceWrite { - writable_roots: vec![writable.path().try_into().unwrap()], - network_access: true, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }), + sandbox_policy: Some(new_policy.clone()), model: Some("o3".to_string()), effort: Some(Some(ReasoningEffort::High)), summary: Some(ReasoningSummary::Detailed), @@ -354,36 +356,18 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an "role": "user", "content": [ { "type": "input_text", "text": "hello 2" } ] }); - // After overriding the turn context, the environment context should be emitted again - // reflecting the new approval policy and sandbox settings. Omit cwd because it did - // not change. - let shell = default_user_shell(); - let expected_env_text_2 = format!( - r#" - never - workspace-write - enabled - - {} - - {} -"#, - writable.path().display(), - shell.name() + let expected_permissions_msg = body1["input"][0].clone(); + // After overriding the turn context, emit a new permissions message. + let body1_input = body1["input"].as_array().expect("input array"); + let expected_permissions_msg_2 = body2["input"][body1_input.len()].clone(); + assert_ne!( + expected_permissions_msg_2, expected_permissions_msg, + "expected updated permissions message after override" ); - let expected_env_msg_2 = serde_json::json!({ - "type": "message", - "role": "user", - "content": [ { "type": "input_text", "text": expected_env_text_2 } ] - }); - let expected_body2 = serde_json::json!( - [ - body1["input"].as_array().unwrap().as_slice(), - [expected_env_msg_2, expected_user_message_2].as_slice(), - ] - .concat() - ); - assert_eq!(body2["input"], expected_body2); + let mut expected_body2 = body1["input"].as_array().expect("input array").to_vec(); + expected_body2.push(expected_permissions_msg_2); + expected_body2.push(expected_user_message_2); + assert_eq!(body2["input"], serde_json::Value::Array(expected_body2)); Ok(()) } @@ -439,10 +423,8 @@ async fn override_before_first_turn_emits_environment_context() -> anyhow::Resul .filter(|text| text.starts_with(ENVIRONMENT_CONTEXT_OPEN_TAG)) .collect(); assert!( - env_texts - .iter() - .any(|text| text.contains("never")), - "environment context should reflect overridden approval policy: {env_texts:?}" + !env_texts.is_empty(), + "expected environment context to be emitted: {env_texts:?}" ); let env_count = input @@ -462,9 +444,29 @@ async fn override_before_first_turn_emits_environment_context() -> anyhow::Resul .is_some() }) .count(); - assert_eq!( - env_count, 2, - "environment context should appear exactly twice, found {env_count}" + assert!( + env_count >= 1, + "environment context should appear at least once, found {env_count}" + ); + + let permissions_texts: Vec<&str> = input + .iter() + .filter_map(|msg| { + let role = msg["role"].as_str()?; + if role != "developer" { + return None; + } + msg["content"] + .as_array() + .and_then(|content| content.first()) + .and_then(|item| item["text"].as_str()) + }) + .collect(); + assert!( + permissions_texts + .iter() + .any(|text| text.contains("`approval_policy` is `never`")), + "permissions message should reflect overridden approval policy: {permissions_texts:?}" ); let user_texts: Vec<&str> = input @@ -514,6 +516,12 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res // Second turn using per-turn overrides via UserTurn let new_cwd = TempDir::new().unwrap(); let writable = TempDir::new().unwrap(); + let new_policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![AbsolutePathBuf::try_from(writable.path()).unwrap()], + network_access: true, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }; codex .submit(Op::UserTurn { items: vec![UserInput::Text { @@ -521,12 +529,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res }], cwd: new_cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::WorkspaceWrite { - writable_roots: vec![AbsolutePathBuf::try_from(writable.path()).unwrap()], - network_access: true, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }, + sandbox_policy: new_policy.clone(), model: "o3".to_string(), effort: Some(ReasoningEffort::High), summary: ReasoningSummary::Detailed, @@ -556,31 +559,28 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res let expected_env_text_2 = format!( r#" {} - never - workspace-write - enabled - - {} - {} "#, new_cwd.path().display(), - writable.path().display(), - shell.name(), + shell.name() ); let expected_env_msg_2 = serde_json::json!({ "type": "message", "role": "user", "content": [ { "type": "input_text", "text": expected_env_text_2 } ] }); - let expected_body2 = serde_json::json!( - [ - body1["input"].as_array().unwrap().as_slice(), - [expected_env_msg_2, expected_user_message_2].as_slice(), - ] - .concat() + let expected_permissions_msg = body1["input"][0].clone(); + let body1_input = body1["input"].as_array().expect("input array"); + let expected_permissions_msg_2 = body2["input"][body1_input.len() + 1].clone(); + assert_ne!( + expected_permissions_msg_2, expected_permissions_msg, + "expected updated permissions message after per-turn override" ); - assert_eq!(body2["input"], expected_body2); + let mut expected_body2 = body1_input.to_vec(); + expected_body2.push(expected_env_msg_2); + expected_body2.push(expected_permissions_msg_2); + expected_body2.push(expected_user_message_2); + assert_eq!(body2["input"], serde_json::Value::Array(expected_body2)); Ok(()) } @@ -648,7 +648,8 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a let body1 = req1.single_request().body_json(); let body2 = req2.single_request().body_json(); - let expected_ui_msg = body1["input"][0].clone(); + let expected_permissions_msg = body1["input"][0].clone(); + let expected_ui_msg = body1["input"][1].clone(); let shell = default_user_shell(); let default_cwd_lossy = default_cwd.to_string_lossy(); @@ -657,6 +658,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a let expected_user_message_1 = text_user_input("hello 1".to_string()); let expected_input_1 = serde_json::Value::Array(vec![ + expected_permissions_msg.clone(), expected_ui_msg.clone(), expected_env_msg_1.clone(), expected_user_message_1.clone(), @@ -665,6 +667,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a let expected_user_message_2 = text_user_input("hello 2".to_string()); let expected_input_2 = serde_json::Value::Array(vec![ + expected_permissions_msg, expected_ui_msg, expected_env_msg_1, expected_user_message_1, @@ -738,34 +741,34 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu let body1 = req1.single_request().body_json(); let body2 = req2.single_request().body_json(); - let expected_ui_msg = body1["input"][0].clone(); + let expected_permissions_msg = body1["input"][0].clone(); + let expected_ui_msg = body1["input"][1].clone(); let shell = default_user_shell(); let expected_env_text_1 = default_env_context_str(&default_cwd.to_string_lossy(), &shell); let expected_env_msg_1 = text_user_input(expected_env_text_1); let expected_user_message_1 = text_user_input("hello 1".to_string()); let expected_input_1 = serde_json::Value::Array(vec![ + expected_permissions_msg.clone(), expected_ui_msg.clone(), expected_env_msg_1.clone(), expected_user_message_1.clone(), ]); assert_eq!(body1["input"], expected_input_1); - let shell_name = shell.name(); - let expected_env_msg_2 = text_user_input(format!( - r#" - never - danger-full-access - enabled - {shell_name} -"# - )); + let body1_input = body1["input"].as_array().expect("input array"); + let expected_permissions_msg_2 = body2["input"][body1_input.len()].clone(); + assert_ne!( + expected_permissions_msg_2, expected_permissions_msg, + "expected updated permissions message after policy change" + ); let expected_user_message_2 = text_user_input("hello 2".to_string()); let expected_input_2 = serde_json::Value::Array(vec![ + expected_permissions_msg, expected_ui_msg, expected_env_msg_1, expected_user_message_1, - expected_env_msg_2, + expected_permissions_msg_2, expected_user_message_2, ]); assert_eq!(body2["input"], expected_input_2); diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index dc6d47fe71..0d47e296c5 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -73,7 +73,8 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { let fixture = test_codex() .with_config(move |config| { - config.mcp_servers.insert( + let mut servers = config.mcp_servers.get().clone(); + servers.insert( server_name.to_string(), McpServerConfig { transport: McpServerTransportConfig::Stdio { @@ -93,6 +94,10 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { disabled_tools: None, }, ); + config + .mcp_servers + .set(servers) + .expect("test mcp servers should accept any configuration"); }) .build(&server) .await?; @@ -204,7 +209,8 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { let fixture = test_codex() .with_config(move |config| { - config.mcp_servers.insert( + let mut servers = config.mcp_servers.get().clone(); + servers.insert( server_name.to_string(), McpServerConfig { transport: McpServerTransportConfig::Stdio { @@ -224,6 +230,10 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { disabled_tools: None, }, ); + config + .mcp_servers + .set(servers) + .expect("test mcp servers should accept any configuration"); }) .build(&server) .await?; @@ -393,7 +403,8 @@ async fn stdio_image_completions_round_trip() -> anyhow::Result<()> { let fixture = test_codex() .with_config(move |config| { config.model_provider.wire_api = codex_core::WireApi::Chat; - config.mcp_servers.insert( + let mut servers = config.mcp_servers.get().clone(); + servers.insert( server_name.to_string(), McpServerConfig { transport: McpServerTransportConfig::Stdio { @@ -413,6 +424,10 @@ async fn stdio_image_completions_round_trip() -> anyhow::Result<()> { disabled_tools: None, }, ); + config + .mcp_servers + .set(servers) + .expect("test mcp servers should accept any configuration"); }) .build(&server) .await?; @@ -533,7 +548,8 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { let fixture = test_codex() .with_config(move |config| { - config.mcp_servers.insert( + let mut servers = config.mcp_servers.get().clone(); + servers.insert( server_name.to_string(), McpServerConfig { transport: McpServerTransportConfig::Stdio { @@ -550,6 +566,10 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { disabled_tools: None, }, ); + config + .mcp_servers + .set(servers) + .expect("test mcp servers should accept any configuration"); }) .build(&server) .await?; @@ -676,7 +696,8 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { let fixture = test_codex() .with_config(move |config| { - config.mcp_servers.insert( + let mut servers = config.mcp_servers.get().clone(); + servers.insert( server_name.to_string(), McpServerConfig { transport: McpServerTransportConfig::StreamableHttp { @@ -692,6 +713,10 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { disabled_tools: None, }, ); + config + .mcp_servers + .set(servers) + .expect("test mcp servers should accept any configuration"); }) .build(&server) .await?; @@ -850,7 +875,8 @@ async fn streamable_http_with_oauth_round_trip() -> anyhow::Result<()> { let fixture = test_codex() .with_config(move |config| { - config.mcp_servers.insert( + let mut servers = config.mcp_servers.get().clone(); + servers.insert( server_name.to_string(), McpServerConfig { transport: McpServerTransportConfig::StreamableHttp { @@ -866,6 +892,10 @@ async fn streamable_http_with_oauth_round_trip() -> anyhow::Result<()> { disabled_tools: None, }, ); + config + .mcp_servers + .set(servers) + .expect("test mcp servers should accept any configuration"); }) .build(&server) .await?; diff --git a/codex-rs/core/tests/suite/stream_no_completed.rs b/codex-rs/core/tests/suite/stream_no_completed.rs index f82aaceaf5..3aa20c0c73 100644 --- a/codex-rs/core/tests/suite/stream_no_completed.rs +++ b/codex-rs/core/tests/suite/stream_no_completed.rs @@ -67,7 +67,7 @@ async fn retries_on_early_close() { name: "openai".into(), base_url: Some(format!("{}/v1", server.uri())), // Environment variable that should exist in the test environment. - // ModelClient will return an error if the environment variable for the + // ModelClientSession will return an error if the environment variable for the // provider is not set. env_key: Some("PATH".into()), env_key_instructions: None, diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index c2bbd2d53c..80204e8e13 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -414,7 +414,8 @@ async fn mcp_tool_call_output_exceeds_limit_truncated_for_model() -> Result<()> let rmcp_test_server_bin = stdio_server_bin()?; let mut builder = test_codex().with_config(move |config| { - config.mcp_servers.insert( + let mut servers = config.mcp_servers.get().clone(); + servers.insert( server_name.to_string(), codex_core::config::types::McpServerConfig { transport: codex_core::config::types::McpServerTransportConfig::Stdio { @@ -431,6 +432,10 @@ async fn mcp_tool_call_output_exceeds_limit_truncated_for_model() -> Result<()> disabled_tools: None, }, ); + config + .mcp_servers + .set(servers) + .expect("test mcp servers should accept any configuration"); config.tool_output_token_limit = Some(500); }); let fixture = builder.build(&server).await?; @@ -497,7 +502,8 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { let openai_png = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMB/ee9bQAAAABJRU5ErkJggg=="; let mut builder = test_codex().with_config(move |config| { - config.mcp_servers.insert( + let mut servers = config.mcp_servers.get().clone(); + servers.insert( server_name.to_string(), McpServerConfig { transport: McpServerTransportConfig::Stdio { @@ -517,6 +523,10 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { disabled_tools: None, }, ); + config + .mcp_servers + .set(servers) + .expect("test mcp servers should accept any configuration"); }); let fixture = builder.build(&server).await?; let session_model = fixture.session_configured.model.clone(); @@ -754,7 +764,8 @@ async fn mcp_tool_call_output_not_truncated_with_custom_limit() -> Result<()> { let mut builder = test_codex().with_config(move |config| { config.tool_output_token_limit = Some(50_000); - config.mcp_servers.insert( + let mut servers = config.mcp_servers.get().clone(); + servers.insert( server_name.to_string(), codex_core::config::types::McpServerConfig { transport: codex_core::config::types::McpServerTransportConfig::Stdio { @@ -771,6 +782,10 @@ async fn mcp_tool_call_output_not_truncated_with_custom_limit() -> Result<()> { disabled_tools: None, }, ); + config + .mcp_servers + .set(servers) + .expect("test mcp servers should accept any configuration"); }); let fixture = builder.build(&server).await?; diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index 8cff14f929..e12e869394 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -28,7 +28,7 @@ pub struct Cli { #[arg(long = "oss", default_value_t = false)] pub oss: bool, - /// Specify which local provider to use (lmstudio or ollama). + /// Specify which local provider to use (lmstudio, ollama, or ollama-chat). /// If not specified with --oss, will use config default or show selection. #[arg(long = "local-provider")] pub oss_provider: Option, diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 7b80f64c50..a887c8bb2f 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -15,9 +15,11 @@ pub use cli::Command; pub use cli::ReviewArgs; use codex_common::oss::ensure_oss_provider_ready; use codex_common::oss::get_default_model_for_oss_provider; +use codex_common::oss::ollama_chat_deprecation_notice; use codex_core::AuthManager; use codex_core::LMSTUDIO_OSS_PROVIDER_ID; use codex_core::NewThread; +use codex_core::OLLAMA_CHAT_PROVIDER_ID; use codex_core::OLLAMA_OSS_PROVIDER_ID; use codex_core::ThreadManager; use codex_core::auth::enforce_login_restrictions; @@ -176,7 +178,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any Some(provider) } else { return Err(anyhow::anyhow!( - "No default OSS provider configured. Use --local-provider=provider or set oss_provider to either {LMSTUDIO_OSS_PROVIDER_ID} or {OLLAMA_OSS_PROVIDER_ID} in config.toml" + "No default OSS provider configured. Use --local-provider=provider or set oss_provider to one of: {LMSTUDIO_OSS_PROVIDER_ID}, {OLLAMA_OSS_PROVIDER_ID}, {OLLAMA_CHAT_PROVIDER_ID} in config.toml" )); } } else { @@ -223,6 +225,14 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any std::process::exit(1); } + let ollama_chat_support_notice = match ollama_chat_deprecation_notice(&config).await { + Ok(notice) => notice, + Err(err) => { + tracing::warn!(?err, "Failed to detect Ollama wire API"); + None + } + }; + let otel = codex_core::otel_init::build_provider(&config, env!("CARGO_PKG_VERSION"), None, false); @@ -253,6 +263,12 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any last_message_file.clone(), )), }; + if let Some(notice) = ollama_chat_support_notice { + event_processor.process_event(Event { + id: String::new(), + msg: EventMsg::DeprecationNotice(notice), + }); + } if oss { // We're in the oss section, so provider_id should be Some diff --git a/codex-rs/execpolicy/src/policy.rs b/codex-rs/execpolicy/src/policy.rs index 0c06d572e4..1e758277b8 100644 --- a/codex-rs/execpolicy/src/policy.rs +++ b/codex-rs/execpolicy/src/policy.rs @@ -61,6 +61,7 @@ impl Policy { Evaluation::from_matches(matched_rules) } + /// Checks multiple commands and aggregates the results. pub fn check_multiple( &self, commands: Commands, @@ -81,12 +82,19 @@ impl Policy { Evaluation::from_matches(matched_rules) } + /// Returns matching rules for the given command. If no rules match and + /// `heuristics_fallback` is provided, returns a single + /// `HeuristicsRuleMatch` with the decision rendered by + /// `heuristics_fallback`. + /// + /// If `heuristics_fallback.is_some()`, then the returned vector is + /// guaranteed to be non-empty. pub fn matches_for_command( &self, cmd: &[String], heuristics_fallback: HeuristicsFallback<'_>, ) -> Vec { - let mut matched_rules: Vec = match cmd.first() { + let matched_rules: Vec = match cmd.first() { Some(first) => self .rules_by_program .get_vec(first) @@ -95,14 +103,16 @@ impl Policy { None => Vec::new(), }; - if let (true, Some(heuristics_fallback)) = (matched_rules.is_empty(), heuristics_fallback) { - matched_rules.push(RuleMatch::HeuristicsRuleMatch { + if matched_rules.is_empty() + && let Some(heuristics_fallback) = heuristics_fallback + { + vec![RuleMatch::HeuristicsRuleMatch { command: cmd.to_vec(), decision: heuristics_fallback(cmd), - }); + }] + } else { + matched_rules } - - matched_rules } } @@ -121,12 +131,11 @@ impl Evaluation { .any(|rule_match| !matches!(rule_match, RuleMatch::HeuristicsRuleMatch { .. })) } + /// Caller is responsible for ensuring that `matched_rules` is non-empty. fn from_matches(matched_rules: Vec) -> Self { - let decision = matched_rules - .iter() - .map(RuleMatch::decision) - .max() - .unwrap_or(Decision::Allow); + let decision = matched_rules.iter().map(RuleMatch::decision).max(); + #[expect(clippy::expect_used)] + let decision = decision.expect("invariant failed: matched_rules must be non-empty"); Self { decision, diff --git a/codex-rs/mcp-server/tests/suite/codex_tool.rs b/codex-rs/mcp-server/tests/suite/codex_tool.rs index d0a78ae392..c0f9310c51 100644 --- a/codex-rs/mcp-server/tests/suite/codex_tool.rs +++ b/codex-rs/mcp-server/tests/suite/codex_tool.rs @@ -381,23 +381,26 @@ async fn codex_tool_passes_base_instructions() -> anyhow::Result<()> { let instructions = request["messages"][0]["content"].as_str().unwrap(); assert!(instructions.starts_with("You are a helpful assistant.")); - let developer_msg = request["messages"] + let developer_messages: Vec<&serde_json::Value> = request["messages"] .as_array() - .and_then(|messages| { - messages - .iter() - .find(|msg| msg.get("role").and_then(|role| role.as_str()) == Some("developer")) - }) - .unwrap(); - let developer_content = developer_msg - .get("content") - .and_then(|value| value.as_str()) - .unwrap(); + .unwrap() + .iter() + .filter(|msg| msg.get("role").and_then(|role| role.as_str()) == Some("developer")) + .collect(); + let developer_contents: Vec<&str> = developer_messages + .iter() + .filter_map(|msg| msg.get("content").and_then(|value| value.as_str())) + .collect(); assert!( - !developer_content.contains('<'), - "expected developer instructions without XML tags, got `{developer_content}`" + developer_contents + .iter() + .any(|content| content.contains("`sandbox_mode`")), + "expected permissions developer message, got {developer_contents:?}" + ); + assert!( + developer_contents.contains(&"Foreshadow upcoming tool calls."), + "expected developer instructions in developer messages, got {developer_contents:?}" ); - assert_eq!(developer_content, "Foreshadow upcoming tool calls."); Ok(()) } diff --git a/codex-rs/ollama/Cargo.toml b/codex-rs/ollama/Cargo.toml index ee16bd5e05..56e8d6e5d8 100644 --- a/codex-rs/ollama/Cargo.toml +++ b/codex-rs/ollama/Cargo.toml @@ -17,6 +17,7 @@ bytes = { workspace = true } codex-core = { workspace = true } futures = { workspace = true } reqwest = { workspace = true, features = ["json", "stream"] } +semver = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true, features = [ "io-std", @@ -30,3 +31,4 @@ wiremock = { workspace = true } [dev-dependencies] assert_matches = { workspace = true } +pretty_assertions = { workspace = true } diff --git a/codex-rs/ollama/src/client.rs b/codex-rs/ollama/src/client.rs index 93244cc2e5..4f603c68b3 100644 --- a/codex-rs/ollama/src/client.rs +++ b/codex-rs/ollama/src/client.rs @@ -1,6 +1,7 @@ use bytes::BytesMut; use futures::StreamExt; use futures::stream::BoxStream; +use semver::Version; use serde_json::Value as JsonValue; use std::collections::VecDeque; use std::io; @@ -53,7 +54,7 @@ impl OllamaClient { } /// Build a client from a provider definition and verify the server is reachable. - async fn try_from_provider(provider: &ModelProviderInfo) -> io::Result { + pub(crate) async fn try_from_provider(provider: &ModelProviderInfo) -> io::Result { #![expect(clippy::expect_used)] let base_url = provider .base_url @@ -125,6 +126,32 @@ impl OllamaClient { Ok(names) } + /// Query the server for its version string, returning `None` when unavailable. + pub async fn fetch_version(&self) -> io::Result> { + let version_url = format!("{}/api/version", self.host_root.trim_end_matches('/')); + let resp = self + .client + .get(version_url) + .send() + .await + .map_err(io::Error::other)?; + if !resp.status().is_success() { + return Ok(None); + } + let val = resp.json::().await.map_err(io::Error::other)?; + let Some(version_str) = val.get("version").and_then(|v| v.as_str()).map(str::trim) else { + return Ok(None); + }; + let normalized = version_str.trim_start_matches('v'); + match Version::parse(normalized) { + Ok(version) => Ok(Some(version)), + Err(err) => { + tracing::warn!("Failed to parse Ollama version `{version_str}`: {err}"); + Ok(None) + } + } + } + /// Start a model pull and emit streaming events. The returned stream ends when /// a Success event is observed or the server closes the connection. pub async fn pull_model_stream( @@ -236,6 +263,7 @@ impl OllamaClient { #[cfg(test)] mod tests { use super::*; + use pretty_assertions::assert_eq; // Happy-path tests using a mock HTTP server; skip if sandbox network is disabled. #[tokio::test] @@ -269,6 +297,42 @@ mod tests { assert!(models.contains(&"mistral".to_string())); } + #[tokio::test] + async fn test_fetch_version() { + if std::env::var(codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { + tracing::info!( + "{} is set; skipping test_fetch_version", + codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR + ); + return; + } + + let server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path("/api/tags")) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_raw( + serde_json::json!({ "models": [] }).to_string(), + "application/json", + )) + .mount(&server) + .await; + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path("/api/version")) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_raw( + serde_json::json!({ "version": "0.14.1" }).to_string(), + "application/json", + )) + .mount(&server) + .await; + + let client = OllamaClient::try_from_provider_with_base_url(server.uri().as_str()) + .await + .expect("client"); + + let version = client.fetch_version().await.expect("version fetch"); + assert_eq!(version, Some(Version::new(0, 14, 1))); + } + #[tokio::test] async fn test_probe_server_happy_path_openai_compat_and_native() { if std::env::var(codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { diff --git a/codex-rs/ollama/src/lib.rs b/codex-rs/ollama/src/lib.rs index 4ced3b6276..b049f0a482 100644 --- a/codex-rs/ollama/src/lib.rs +++ b/codex-rs/ollama/src/lib.rs @@ -4,15 +4,23 @@ mod pull; mod url; pub use client::OllamaClient; +use codex_core::ModelProviderInfo; +use codex_core::WireApi; use codex_core::config::Config; pub use pull::CliProgressReporter; pub use pull::PullEvent; pub use pull::PullProgressReporter; pub use pull::TuiProgressReporter; +use semver::Version; /// Default OSS model to use when `--oss` is passed without an explicit `-m`. pub const DEFAULT_OSS_MODEL: &str = "gpt-oss:20b"; +pub struct WireApiDetection { + pub wire_api: WireApi, + pub version: Option, +} + /// Prepare the local OSS environment when `--oss` is selected. /// /// - Ensures a local Ollama server is reachable. @@ -45,3 +53,65 @@ pub async fn ensure_oss_ready(config: &Config) -> std::io::Result<()> { Ok(()) } + +fn min_responses_version() -> Version { + Version::new(0, 13, 4) +} + +fn wire_api_for_version(version: &Version) -> WireApi { + if *version == Version::new(0, 0, 0) || *version >= min_responses_version() { + WireApi::Responses + } else { + WireApi::Chat + } +} + +/// Detect which wire API the running Ollama server supports based on its version. +/// Returns `Ok(None)` when the version endpoint is missing or unparsable; callers +/// should keep the configured default in that case. +pub async fn detect_wire_api( + provider: &ModelProviderInfo, +) -> std::io::Result> { + let client = crate::OllamaClient::try_from_provider(provider).await?; + let Some(version) = client.fetch_version().await? else { + return Ok(None); + }; + + let wire_api = wire_api_for_version(&version); + + Ok(Some(WireApiDetection { + wire_api, + version: Some(version), + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_wire_api_for_version_dev_zero_keeps_responses() { + assert_eq!( + wire_api_for_version(&Version::new(0, 0, 0)), + WireApi::Responses + ); + } + + #[test] + fn test_wire_api_for_version_before_cutoff_is_chat() { + assert_eq!(wire_api_for_version(&Version::new(0, 13, 3)), WireApi::Chat); + } + + #[test] + fn test_wire_api_for_version_at_or_after_cutoff_is_responses() { + assert_eq!( + wire_api_for_version(&Version::new(0, 13, 4)), + WireApi::Responses + ); + assert_eq!( + wire_api_for_version(&Version::new(0, 14, 0)), + WireApi::Responses + ); + } +} diff --git a/codex-rs/otel/src/lib.rs b/codex-rs/otel/src/lib.rs index dc53fd0119..868a977735 100644 --- a/codex-rs/otel/src/lib.rs +++ b/codex-rs/otel/src/lib.rs @@ -17,7 +17,6 @@ use codex_protocol::ThreadId; use serde::Serialize; use std::time::Duration; use strum_macros::Display; -use tracing::Span; #[derive(Debug, Clone, Serialize, Display)] #[serde(rename_all = "snake_case")] @@ -42,7 +41,6 @@ pub struct OtelEventMetadata { #[derive(Debug, Clone)] pub struct OtelManager { pub(crate) metadata: OtelEventMetadata, - pub(crate) session_span: Span, pub(crate) metrics: Option, pub(crate) metrics_use_metadata_tags: bool, } diff --git a/codex-rs/otel/src/traces/otel_manager.rs b/codex-rs/otel/src/traces/otel_manager.rs index 368a8e3387..44d2416ac0 100644 --- a/codex-rs/otel/src/traces/otel_manager.rs +++ b/codex-rs/otel/src/traces/otel_manager.rs @@ -23,7 +23,6 @@ use std::time::Duration; use std::time::Instant; use tokio::time::error::Elapsed; use tracing::Span; -use tracing::trace_span; use tracing_opentelemetry::OpenTelemetrySpanExt; pub use crate::OtelEventMetadata; @@ -41,14 +40,8 @@ impl OtelManager { auth_mode: Option, log_user_prompts: bool, terminal_type: String, - session_source: SessionSource, + _session_source: SessionSource, ) -> OtelManager { - let session_span = trace_span!("new_session", conversation_id = %conversation_id, session_source = %session_source); - - if let Some(context) = traceparent_context_from_env() { - let _ = session_span.set_parent(context); - } - Self { metadata: OtelEventMetadata { conversation_id, @@ -61,14 +54,15 @@ impl OtelManager { app_version: env!("CARGO_PKG_VERSION"), terminal_type, }, - session_span, metrics: crate::metrics::global(), metrics_use_metadata_tags: true, } } - pub fn current_span(&self) -> &Span { - &self.session_span + pub fn apply_traceparent_parent(&self, span: &Span) { + if let Some(context) = traceparent_context_from_env() { + let _ = span.set_parent(context); + } } pub fn record_responses(&self, handle_responses_span: &Span, event: &ResponseEvent) { diff --git a/codex-rs/protocol/BUILD.bazel b/codex-rs/protocol/BUILD.bazel index e699c7bf9a..2c00d076bc 100644 --- a/codex-rs/protocol/BUILD.bazel +++ b/codex-rs/protocol/BUILD.bazel @@ -3,4 +3,5 @@ load("//:defs.bzl", "codex_rust_crate") codex_rust_crate( name = "protocol", crate_name = "codex_protocol", + compile_data = glob(["src/prompts/permissions/**/*.md"]), ) diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index bec2a35f5a..04f54b1dcc 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::path::Path; use codex_utils_image::load_and_resize_to_fit; use mcp_types::CallToolResult; @@ -9,6 +10,11 @@ use serde::Serialize; use serde::ser::Serializer; use ts_rs::TS; +use crate::config_types::SandboxMode; +use crate::protocol::AskForApproval; +use crate::protocol::NetworkAccess; +use crate::protocol::SandboxPolicy; +use crate::protocol::WritableRoot; use crate::user_input::UserInput; use codex_git::GhostCommit; use codex_utils_image::error::ImageProcessingError; @@ -158,6 +164,159 @@ pub enum ResponseItem { Other, } +/// Developer-provided guidance that is injected into a turn as a developer role +/// message. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] +#[serde(rename = "developer_instructions", rename_all = "snake_case")] +pub struct DeveloperInstructions { + text: String, +} + +const APPROVAL_POLICY_NEVER: &str = include_str!("prompts/permissions/approval_policy/never.md"); +const APPROVAL_POLICY_UNLESS_TRUSTED: &str = + include_str!("prompts/permissions/approval_policy/unless_trusted.md"); +const APPROVAL_POLICY_ON_FAILURE: &str = + include_str!("prompts/permissions/approval_policy/on_failure.md"); +const APPROVAL_POLICY_ON_REQUEST: &str = + include_str!("prompts/permissions/approval_policy/on_request.md"); + +const SANDBOX_MODE_DANGER_FULL_ACCESS: &str = + include_str!("prompts/permissions/sandbox_mode/danger_full_access.md"); +const SANDBOX_MODE_WORKSPACE_WRITE: &str = + include_str!("prompts/permissions/sandbox_mode/workspace_write.md"); +const SANDBOX_MODE_READ_ONLY: &str = include_str!("prompts/permissions/sandbox_mode/read_only.md"); + +impl DeveloperInstructions { + pub fn new>(text: T) -> Self { + Self { text: text.into() } + } + + pub fn into_text(self) -> String { + self.text + } + + pub fn concat(self, other: impl Into) -> Self { + let mut text = self.text; + text.push_str(&other.into().text); + Self { text } + } + + pub fn from_policy( + sandbox_policy: &SandboxPolicy, + approval_policy: AskForApproval, + cwd: &Path, + ) -> Self { + let network_access = if sandbox_policy.has_full_network_access() { + NetworkAccess::Enabled + } else { + NetworkAccess::Restricted + }; + + let (sandbox_mode, writable_roots) = match sandbox_policy { + SandboxPolicy::DangerFullAccess => (SandboxMode::DangerFullAccess, None), + SandboxPolicy::ReadOnly => (SandboxMode::ReadOnly, None), + SandboxPolicy::ExternalSandbox { .. } => (SandboxMode::DangerFullAccess, None), + SandboxPolicy::WorkspaceWrite { .. } => { + let roots = sandbox_policy.get_writable_roots_with_cwd(cwd); + (SandboxMode::WorkspaceWrite, Some(roots)) + } + }; + + DeveloperInstructions::from_permissions_with_network( + sandbox_mode, + network_access, + approval_policy, + writable_roots, + ) + } + + fn from_permissions_with_network( + sandbox_mode: SandboxMode, + network_access: NetworkAccess, + approval_policy: AskForApproval, + writable_roots: Option>, + ) -> Self { + let start_tag = DeveloperInstructions::new(""); + let end_tag = DeveloperInstructions::new(""); + start_tag + .concat(DeveloperInstructions::sandbox_text( + sandbox_mode, + network_access, + )) + .concat(DeveloperInstructions::from(approval_policy)) + .concat(DeveloperInstructions::from_writable_roots(writable_roots)) + .concat(end_tag) + } + + fn from_writable_roots(writable_roots: Option>) -> Self { + let Some(roots) = writable_roots else { + return DeveloperInstructions::new(""); + }; + + if roots.is_empty() { + return DeveloperInstructions::new(""); + } + + let roots_list: Vec = roots + .iter() + .map(|r| format!("`{}`", r.root.to_string_lossy())) + .collect(); + let text = if roots_list.len() == 1 { + format!(" The writable root is {}.", roots_list[0]) + } else { + format!(" The writable roots are {}.", roots_list.join(", ")) + }; + DeveloperInstructions::new(text) + } + + fn sandbox_text(mode: SandboxMode, network_access: NetworkAccess) -> DeveloperInstructions { + let template = match mode { + SandboxMode::DangerFullAccess => SANDBOX_MODE_DANGER_FULL_ACCESS.trim_end(), + SandboxMode::WorkspaceWrite => SANDBOX_MODE_WORKSPACE_WRITE.trim_end(), + SandboxMode::ReadOnly => SANDBOX_MODE_READ_ONLY.trim_end(), + }; + let text = template.replace("{network_access}", &network_access.to_string()); + + DeveloperInstructions::new(text) + } +} + +impl From for ResponseItem { + fn from(di: DeveloperInstructions) -> Self { + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: di.into_text(), + }], + } + } +} + +impl From for DeveloperInstructions { + fn from(mode: SandboxMode) -> Self { + let network_access = match mode { + SandboxMode::DangerFullAccess => NetworkAccess::Enabled, + SandboxMode::WorkspaceWrite | SandboxMode::ReadOnly => NetworkAccess::Restricted, + }; + + DeveloperInstructions::sandbox_text(mode, network_access) + } +} + +impl From for DeveloperInstructions { + fn from(mode: AskForApproval) -> Self { + let text = match mode { + AskForApproval::Never => APPROVAL_POLICY_NEVER.trim_end(), + AskForApproval::UnlessTrusted => APPROVAL_POLICY_UNLESS_TRUSTED.trim_end(), + AskForApproval::OnFailure => APPROVAL_POLICY_ON_FAILURE.trim_end(), + AskForApproval::OnRequest => APPROVAL_POLICY_ON_REQUEST.trim_end(), + }; + + DeveloperInstructions::new(text) + } +} + fn should_serialize_reasoning_content(content: &Option>) -> bool { match content { Some(content) => !content @@ -625,12 +784,71 @@ impl std::ops::Deref for FunctionCallOutputPayload { #[cfg(test)] mod tests { use super::*; + use crate::config_types::SandboxMode; + use crate::protocol::AskForApproval; use anyhow::Result; use mcp_types::ImageContent; use mcp_types::TextContent; use pretty_assertions::assert_eq; + use std::path::PathBuf; use tempfile::tempdir; + #[test] + fn converts_sandbox_mode_into_developer_instructions() { + assert_eq!( + DeveloperInstructions::from(SandboxMode::WorkspaceWrite), + DeveloperInstructions::new( + "Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `workspace-write`: The sandbox permits reading files, and editing files in `cwd` and `writable_roots`. Editing files in other directories requires approval. Network access is restricted." + ) + ); + + assert_eq!( + DeveloperInstructions::from(SandboxMode::ReadOnly), + DeveloperInstructions::new( + "Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `read-only`: The sandbox only permits reading files. Network access is restricted." + ) + ); + } + + #[test] + fn builds_permissions_with_network_access_override() { + let instructions = DeveloperInstructions::from_permissions_with_network( + SandboxMode::WorkspaceWrite, + NetworkAccess::Enabled, + AskForApproval::OnRequest, + None, + ); + + let text = instructions.into_text(); + assert!( + text.contains("Network access is enabled."), + "expected network access to be enabled in message" + ); + assert!( + text.contains("`approval_policy` is `on-request`"), + "expected approval guidance to be included" + ); + } + + #[test] + fn builds_permissions_from_policy() { + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + network_access: true, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }; + + let instructions = DeveloperInstructions::from_policy( + &policy, + AskForApproval::UnlessTrusted, + &PathBuf::from("/tmp"), + ); + let text = instructions.into_text(); + assert!(text.contains("Network access is enabled.")); + assert!(text.contains("`approval_policy` is `unless-trusted`")); + } + #[test] fn serializes_success_as_plain_string() -> Result<()> { let item = ResponseInputItem::FunctionCallOutput { diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index 60c7cc74b0..dbda4aebaf 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -54,6 +54,7 @@ pub struct ModelUpgrade { pub migration_config_key: String, pub model_link: Option, pub upgrade_copy: Option, + pub migration_markdown: Option, } /// Metadata describing a Codex-supported model. @@ -234,6 +235,7 @@ impl From for ModelPreset { // todo(aibrahim): add the model link here. model_link: None, upgrade_copy: None, + migration_markdown: None, }), show_in_picker: info.visibility == ModelVisibility::List, supported_in_api: info.supported_in_api, diff --git a/codex-rs/protocol/src/prompts/permissions/approval_policy/never.md b/codex-rs/protocol/src/prompts/permissions/approval_policy/never.md new file mode 100644 index 0000000000..be8fe34e64 --- /dev/null +++ b/codex-rs/protocol/src/prompts/permissions/approval_policy/never.md @@ -0,0 +1 @@ + Approvals are your mechanism to get user consent to run shell commands without the sandbox. `approval_policy` is `never`: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is paired with `danger-full-access`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding. diff --git a/codex-rs/protocol/src/prompts/permissions/approval_policy/on_failure.md b/codex-rs/protocol/src/prompts/permissions/approval_policy/on_failure.md new file mode 100644 index 0000000000..7ee26dbd49 --- /dev/null +++ b/codex-rs/protocol/src/prompts/permissions/approval_policy/on_failure.md @@ -0,0 +1 @@ +Approvals are your mechanism to get user consent to run shell commands without the sandbox. `approval_policy` is `on-failure`: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox. diff --git a/codex-rs/protocol/src/prompts/permissions/approval_policy/on_request.md b/codex-rs/protocol/src/prompts/permissions/approval_policy/on_request.md new file mode 100644 index 0000000000..16c90e8012 --- /dev/null +++ b/codex-rs/protocol/src/prompts/permissions/approval_policy/on_request.md @@ -0,0 +1,12 @@ + Approvals are your mechanism to get user consent to run shell commands without the sandbox. `approval_policy` is `on-request`: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. If the completing the task requires escalated permissions, Do not let these settings or the sandbox deter you from attempting to accomplish the user's task. + +Here are scenarios where you'll need to request approval: +- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /var) +- You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files. +- You are running sandboxed and need to run a command that requires network access (e.g. installing packages) +- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `sandbox_permissions` and `justification` parameters - do not message the user before requesting approval for the command. +- You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for. + +When requesting approval to execute a command that will require escalated privileges: + - Provide the `sandbox_permissions` parameter with the value `"require_escalated"` + - Include a short, 1 sentence explanation for why you need escalated permissions in the justification parameter \ No newline at end of file diff --git a/codex-rs/protocol/src/prompts/permissions/approval_policy/unless_trusted.md b/codex-rs/protocol/src/prompts/permissions/approval_policy/unless_trusted.md new file mode 100644 index 0000000000..039f702656 --- /dev/null +++ b/codex-rs/protocol/src/prompts/permissions/approval_policy/unless_trusted.md @@ -0,0 +1 @@ + Approvals are your mechanism to get user consent to run shell commands without the sandbox. `approval_policy` is `unless-trusted`: The harness will escalate most commands for user approval, apart from a limited allowlist of safe "read" commands. diff --git a/codex-rs/protocol/src/prompts/permissions/sandbox_mode/danger_full_access.md b/codex-rs/protocol/src/prompts/permissions/sandbox_mode/danger_full_access.md new file mode 100644 index 0000000000..4a5cfa9fb1 --- /dev/null +++ b/codex-rs/protocol/src/prompts/permissions/sandbox_mode/danger_full_access.md @@ -0,0 +1 @@ +Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `danger-full-access`: No filesystem sandboxing - all commands are permitted. Network access is {network_access}. diff --git a/codex-rs/protocol/src/prompts/permissions/sandbox_mode/read_only.md b/codex-rs/protocol/src/prompts/permissions/sandbox_mode/read_only.md new file mode 100644 index 0000000000..729264a11f --- /dev/null +++ b/codex-rs/protocol/src/prompts/permissions/sandbox_mode/read_only.md @@ -0,0 +1 @@ +Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `read-only`: The sandbox only permits reading files. Network access is {network_access}. diff --git a/codex-rs/protocol/src/prompts/permissions/sandbox_mode/workspace_write.md b/codex-rs/protocol/src/prompts/permissions/sandbox_mode/workspace_write.md new file mode 100644 index 0000000000..ae74b5f762 --- /dev/null +++ b/codex-rs/protocol/src/prompts/permissions/sandbox_mode/workspace_write.md @@ -0,0 +1 @@ +Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `workspace-write`: The sandbox permits reading files, and editing files in `cwd` and `writable_roots`. Editing files in other directories requires approval. Network access is {network_access}. diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 4f6314104d..54cfba8eb1 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -60,6 +60,13 @@ pub struct Submission { pub op: Op, } +/// Config payload for refreshing MCP servers. +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)] +pub struct McpServerRefreshConfig { + pub mcp_servers: Value, + pub mcp_oauth_credentials_store_mode: Value, +} + /// Submission operation #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)] #[serde(tag = "type", rename_all = "snake_case")] @@ -95,7 +102,7 @@ pub enum Op { /// Policy to use for tool calls such as `local_shell`. sandbox_policy: SandboxPolicy, - /// Must be a valid model slug for the [`crate::client::ModelClient`] + /// Must be a valid model slug for the configured client session /// associated with this conversation. model: String, @@ -186,6 +193,9 @@ pub enum Op { /// Reply is delivered via `EventMsg::McpListToolsResponse`. ListMcpTools, + /// Request MCP servers to reinitialize and refresh cached tool lists. + RefreshMcpServers { config: McpServerRefreshConfig }, + /// Request the list of available custom prompts. ListCustomPrompts, diff --git a/codex-rs/rmcp-client/Cargo.toml b/codex-rs/rmcp-client/Cargo.toml index efcea2d805..8aa7512faf 100644 --- a/codex-rs/rmcp-client/Cargo.toml +++ b/codex-rs/rmcp-client/Cargo.toml @@ -36,6 +36,7 @@ rmcp = { workspace = true, default-features = false, features = [ "transport-streamable-http-client-reqwest", "transport-streamable-http-server", ] } +schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } sha2 = { workspace = true } diff --git a/codex-rs/rmcp-client/src/oauth.rs b/codex-rs/rmcp-client/src/oauth.rs index f8eafaf23e..a3a256374b 100644 --- a/codex-rs/rmcp-client/src/oauth.rs +++ b/codex-rs/rmcp-client/src/oauth.rs @@ -26,6 +26,7 @@ use oauth2::Scope; use oauth2::TokenResponse; use oauth2::basic::BasicTokenType; use rmcp::transport::auth::OAuthTokenResponse; +use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use serde_json::Value; @@ -63,7 +64,7 @@ pub struct StoredOAuthTokens { } /// Determine where Codex should store and read MCP credentials. -#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "lowercase")] pub enum OAuthCredentialsStoreMode { /// `Keyring` when available; otherwise, `File`. diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index ad5b32c821..312d4e0632 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -35,6 +35,7 @@ use codex_core::features::Feature; use codex_core::models_manager::manager::ModelsManager; use codex_core::models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; use codex_core::models_manager::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; +use codex_core::protocol::DeprecationNoticeEvent; use codex_core::protocol::EventMsg; use codex_core::protocol::FinalOutput; use codex_core::protocol::ListSkillsResponseEvent; @@ -121,6 +122,15 @@ fn emit_skill_load_warnings(app_event_tx: &AppEventSender, errors: &[SkillErrorI } } +fn emit_deprecation_notice(app_event_tx: &AppEventSender, notice: Option) { + let Some(DeprecationNoticeEvent { summary, details }) = notice else { + return; + }; + app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + crate::history_cell::new_deprecation_notice(summary, details), + ))); +} + #[derive(Debug, Clone, PartialEq, Eq)] struct SessionSummary { usage_line: String, @@ -201,6 +211,7 @@ async fn handle_model_migration_prompt_if_needed( migration_config_key, model_link, upgrade_copy, + migration_markdown, }) = upgrade { if migration_prompt_hidden(config, migration_config_key.as_str()) { @@ -234,6 +245,7 @@ async fn handle_model_migration_prompt_if_needed( &target_model, model_link.clone(), upgrade_copy.clone(), + migration_markdown.clone(), heading_label, target_description, can_opt_out, @@ -343,10 +355,12 @@ impl App { session_selection: SessionSelection, feedback: codex_feedback::CodexFeedback, is_first_run: bool, + ollama_chat_support_notice: Option, ) -> Result { use tokio_stream::StreamExt; let (app_event_tx, mut app_event_rx) = unbounded_channel(); let app_event_tx = AppEventSender::new(app_event_tx); + emit_deprecation_notice(&app_event_tx, ollama_chat_support_notice); let thread_manager = Arc::new(ThreadManager::new( config.codex_home.clone(), @@ -541,7 +555,7 @@ impl App { // [tui-textarea]: https://github.com/rhysd/tui-textarea/blob/4d18622eeac13b309e0ff6a55a46ac6706da68cf/src/textarea.rs#L782-L783 // [iTerm2]: https://github.com/gnachman/iTerm2/blob/5d0c0d9f68523cbd0494dad5422998964a2ecd8d/sources/iTermPasteHelper.m#L206-L216 let pasted = pasted.replace("\r", "\n"); - self.chat_widget.handle_paste(pasted); + self.chat_widget.handle_paste_event(pasted); } TuiEvent::Draw => { self.chat_widget.maybe_post_pending_notification(tui); @@ -811,9 +825,6 @@ impl App { } self.chat_widget.handle_codex_event(event); } - AppEvent::ConversationHistory(ev) => { - self.on_conversation_history_for_backtrack(tui, ev).await?; - } AppEvent::ExitRequest => { return Ok(false); } @@ -1435,8 +1446,9 @@ impl App { && self.backtrack.nth_user_message != usize::MAX && self.chat_widget.composer_is_empty() => { - // Delegate to helper for clarity; preserves behavior. - self.confirm_backtrack_from_main(); + if let Some(selection) = self.confirm_backtrack_from_main() { + self.apply_backtrack_selection(tui, selection); + } } KeyEvent { kind: KeyEventKind::Press | KeyEventKind::Repeat, @@ -1507,6 +1519,7 @@ mod tests { use codex_core::protocol::SessionConfiguredEvent; use codex_protocol::ThreadId; use insta::assert_snapshot; + use pretty_assertions::assert_eq; use ratatui::prelude::Line; use std::path::PathBuf; use std::sync::Arc; @@ -1598,6 +1611,9 @@ mod tests { fn model_migration_copy_to_plain_text( copy: &crate::model_migration::ModelMigrationCopy, ) -> String { + if let Some(markdown) = copy.markdown.as_ref() { + return markdown.clone(); + } let mut s = String::new(); for span in ©.heading { s.push_str(&span.content); @@ -1680,6 +1696,7 @@ mod tests { migration_config_key: HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG.to_string(), model_link: None, upgrade_copy: None, + migration_markdown: None, }); available.retain(|preset| preset.model != "gpt-5-codex"); available.push(current.clone()); @@ -1735,6 +1752,7 @@ mod tests { &upgrade.id, upgrade.model_link.clone(), upgrade.upgrade_copy.clone(), + upgrade.migration_markdown.clone(), target.display_name.clone(), target_description, can_opt_out, @@ -1768,7 +1786,7 @@ mod tests { #[tokio::test] async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { - let mut app = make_test_app().await; + let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await; let user_cell = |text: &str| -> Arc { Arc::new(UserHistoryCell { @@ -1804,9 +1822,8 @@ mod tests { )) as Arc }; - // Simulate the transcript after trimming for a fork, replaying history, and - // appending the edited turn. The session header separates the retained history - // from the forked thread's replayed turns. + // Simulate a transcript with duplicated history (e.g., from prior backtracks) + // and an edited turn appended after a session header boundary. app.transcript_cells = vec![ make_header(true), user_cell("first question"), @@ -1822,15 +1839,44 @@ mod tests { assert_eq!(user_count(&app.transcript_cells), 2); - app.backtrack.base_id = Some(ThreadId::new()); + let base_id = ThreadId::new(); + app.chat_widget.handle_codex_event(Event { + id: String::new(), + msg: EventMsg::SessionConfigured(SessionConfiguredEvent { + session_id: base_id, + model: "gpt-test".to_string(), + model_provider_id: "test-provider".to_string(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::ReadOnly, + cwd: PathBuf::from("/home/user/project"), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + rollout_path: PathBuf::new(), + }), + }); + + app.backtrack.base_id = Some(base_id); app.backtrack.primed = true; app.backtrack.nth_user_message = user_count(&app.transcript_cells).saturating_sub(1); - app.confirm_backtrack_from_main(); + let selection = app + .confirm_backtrack_from_main() + .expect("backtrack selection"); + assert_eq!(selection.nth_user_message, 1); + assert_eq!(selection.prefill, "follow-up (edited)"); - let (_, nth, prefill) = app.backtrack.pending.clone().expect("pending backtrack"); - assert_eq!(nth, 1); - assert_eq!(prefill, "follow-up (edited)"); + app.apply_backtrack_rollback(selection); + + let mut rollback_turns = None; + while let Ok(op) = op_rx.try_recv() { + if let Op::ThreadRollback { num_turns } = op { + rollback_turns = Some(num_turns); + } + } + + assert_eq!(rollback_turns, Some(1)); } #[tokio::test] diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index c28680dd93..43dacb573a 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -1,5 +1,17 @@ +//! Backtracking and transcript overlay event routing. +//! +//! This file owns backtrack mode (Esc/Enter navigation in the transcript overlay) and also +//! mediates a key rendering boundary for the transcript overlay. +//! +//! The transcript overlay (`Ctrl+T`) renders committed transcript cells plus a render-only live +//! tail derived from the current in-flight `ChatWidget.active_cell`. +//! +//! That live tail is kept in sync during `TuiEvent::Draw` handling for `Overlay::Transcript` by +//! asking `ChatWidget` for an active-cell cache key and transcript lines and by passing them into +//! `TranscriptOverlay::sync_live_tail`. This preserves the invariant that the overlay reflects +//! both committed history and in-flight activity without changing flush or coalescing behavior. + use std::any::TypeId; -use std::path::PathBuf; use std::sync::Arc; use crate::app::App; @@ -8,7 +20,7 @@ use crate::history_cell::UserHistoryCell; use crate::pager_overlay::Overlay; use crate::tui; use crate::tui::TuiEvent; -use codex_core::protocol::ConversationPathResponseEvent; +use codex_core::protocol::Op; use codex_protocol::ThreadId; use color_eyre::eyre::Result; use crossterm::event::KeyCode; @@ -20,14 +32,18 @@ use crossterm::event::KeyEventKind; pub(crate) struct BacktrackState { /// True when Esc has primed backtrack mode in the main view. pub(crate) primed: bool, - /// Session id of the base thread to fork from. + /// Session id of the base thread to rollback. pub(crate) base_id: Option, /// Index in the transcript of the last user message. pub(crate) nth_user_message: usize, /// True when the transcript overlay is showing a backtrack preview. pub(crate) overlay_preview_active: bool, - /// Pending fork request: (base_id, nth_user_message, prefill). - pub(crate) pending: Option<(ThreadId, usize, String)>, +} + +#[derive(Debug, Clone)] +pub(crate) struct BacktrackSelection { + pub(crate) nth_user_message: usize, + pub(crate) prefill: String, } impl App { @@ -96,22 +112,22 @@ impl App { } /// Stage a backtrack and request thread history from the agent. - pub(crate) fn request_backtrack( - &mut self, - prefill: String, - base_id: ThreadId, - nth_user_message: usize, - ) { - self.backtrack.pending = Some((base_id, nth_user_message, prefill)); - if let Some(path) = self.chat_widget.rollout_path() { - let ev = ConversationPathResponseEvent { - conversation_id: base_id, - path, - }; - self.app_event_tx - .send(crate::app_event::AppEvent::ConversationHistory(ev)); - } else { - tracing::error!("rollout path unavailable; cannot backtrack"); + pub(crate) fn apply_backtrack_rollback(&mut self, selection: BacktrackSelection) { + let user_total = user_count(&self.transcript_cells); + if user_total == 0 { + return; + } + + let num_turns = user_total.saturating_sub(selection.nth_user_message); + let num_turns = u32::try_from(num_turns).unwrap_or(u32::MAX); + if num_turns == 0 { + return; + } + + self.chat_widget.submit_op(Op::ThreadRollback { num_turns }); + self.trim_transcript_for_backtrack(selection.nth_user_message); + if !selection.prefill.is_empty() { + self.chat_widget.set_composer_text(selection.prefill); } } @@ -173,7 +189,7 @@ impl App { self.backtrack.overlay_preview_active = true; let count = user_count(&self.transcript_cells); if let Some(last) = count.checked_sub(1) { - self.apply_backtrack_selection(last); + self.apply_backtrack_selection_internal(last); } tui.frame_requester().schedule_frame(); } @@ -197,12 +213,12 @@ impl App { .min(last_index) }; - self.apply_backtrack_selection(next_selection); + self.apply_backtrack_selection_internal(next_selection); tui.frame_requester().schedule_frame(); } /// Apply a computed backtrack selection to the overlay and internal counter. - fn apply_backtrack_selection(&mut self, nth_user_message: usize) { + fn apply_backtrack_selection_internal(&mut self, nth_user_message: usize) { if let Some(cell_idx) = nth_user_position(&self.transcript_cells, nth_user_message) { self.backtrack.nth_user_message = nth_user_message; if let Some(Overlay::Transcript(t)) = &mut self.overlay { @@ -216,8 +232,47 @@ impl App { } } - /// Forward any event to the overlay and close it if done. + /// Forwards an event to the overlay and closes it if done. + /// + /// The transcript overlay draw path is special because the overlay should match the main + /// viewport while the active cell is still streaming or mutating. + /// + /// `TranscriptOverlay` owns committed transcript cells, while `ChatWidget` owns the current + /// in-flight active cell (often a coalesced exec/tool group). During draws we append that + /// in-flight cell as a cached, render-only live tail so `Ctrl+T` does not appear to "lose" tool + /// calls until a later flush boundary. + /// + /// This logic lives here (instead of inside the overlay widget) because `ChatWidget` is the + /// source of truth for the active cell and its cache invalidation key, and because `App` owns + /// overlay lifecycle and frame scheduling for animations. fn overlay_forward_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> { + if let TuiEvent::Draw = &event + && let Some(Overlay::Transcript(t)) = &mut self.overlay + { + let active_key = self.chat_widget.active_cell_transcript_key(); + let chat_widget = &self.chat_widget; + tui.draw(u16::MAX, |frame| { + let width = frame.area().width.max(1); + t.sync_live_tail(width, active_key, |w| { + chat_widget.active_cell_transcript_lines(w) + }); + t.render(frame.area(), frame.buffer); + })?; + let close_overlay = t.is_done(); + if !close_overlay + && active_key.is_some_and(|key| key.animation_tick.is_some()) + && t.is_scrolled_to_bottom() + { + tui.frame_requester() + .schedule_frame_in(std::time::Duration::from_millis(50)); + } + if close_overlay { + self.close_transcript_overlay(tui); + tui.frame_requester().schedule_frame(); + } + return Ok(()); + } + if let Some(overlay) = &mut self.overlay { overlay.handle_event(tui, event)?; if overlay.is_done() { @@ -231,16 +286,13 @@ impl App { /// Handle Enter in overlay backtrack preview: confirm selection and reset state. fn overlay_confirm_backtrack(&mut self, tui: &mut tui::Tui) { let nth_user_message = self.backtrack.nth_user_message; - if let Some(base_id) = self.backtrack.base_id { - let prefill = nth_user_position(&self.transcript_cells, nth_user_message) - .and_then(|idx| self.transcript_cells.get(idx)) - .and_then(|cell| cell.as_any().downcast_ref::()) - .map(|c| c.message.clone()) - .unwrap_or_default(); - self.close_transcript_overlay(tui); - self.request_backtrack(prefill, base_id, nth_user_message); + let selection = self.backtrack_selection(nth_user_message); + self.close_transcript_overlay(tui); + if let Some(selection) = selection { + self.apply_backtrack_rollback(selection); + self.render_transcript_once(tui); + tui.frame_requester().schedule_frame(); } - self.reset_backtrack_state(); } /// Handle Esc in overlay backtrack preview: step selection if armed, else forward. @@ -254,18 +306,11 @@ impl App { } /// Confirm a primed backtrack from the main view (no overlay visible). - /// Computes the prefill from the selected user message and requests history. - pub(crate) fn confirm_backtrack_from_main(&mut self) { - if let Some(base_id) = self.backtrack.base_id { - let prefill = - nth_user_position(&self.transcript_cells, self.backtrack.nth_user_message) - .and_then(|idx| self.transcript_cells.get(idx)) - .and_then(|cell| cell.as_any().downcast_ref::()) - .map(|c| c.message.clone()) - .unwrap_or_default(); - self.request_backtrack(prefill, base_id, self.backtrack.nth_user_message); - } + /// Computes the prefill from the selected user message for rollback. + pub(crate) fn confirm_backtrack_from_main(&mut self) -> Option { + let selection = self.backtrack_selection(self.backtrack.nth_user_message); self.reset_backtrack_state(); + selection } /// Clear all backtrack-related state and composer hints. @@ -277,89 +322,34 @@ impl App { self.chat_widget.clear_esc_backtrack_hint(); } - /// Handle a ConversationHistory response while a backtrack is pending. - /// If it matches the primed base session, fork and switch to the new conversation. - pub(crate) async fn on_conversation_history_for_backtrack( + pub(crate) fn apply_backtrack_selection( &mut self, tui: &mut tui::Tui, - ev: ConversationPathResponseEvent, - ) -> Result<()> { - if let Some((base_id, _, _)) = self.backtrack.pending.as_ref() - && ev.conversation_id == *base_id - && let Some((_, nth_user_message, prefill)) = self.backtrack.pending.take() - { - self.fork_and_switch_to_new_conversation(tui, ev, nth_user_message, prefill) - .await; - } - Ok(()) - } - - /// Fork the conversation using provided history and switch UI/state accordingly. - async fn fork_and_switch_to_new_conversation( - &mut self, - tui: &mut tui::Tui, - ev: ConversationPathResponseEvent, - nth_user_message: usize, - prefill: String, + selection: BacktrackSelection, ) { - let cfg = self.chat_widget.config_ref().clone(); - // Perform the fork via a thin wrapper for clarity/testability. - let result = self - .perform_fork(ev.path.clone(), nth_user_message, cfg.clone()) - .await; - match result { - Ok(new_conv) => { - self.install_forked_conversation(tui, cfg, new_conv, nth_user_message, &prefill) - } - Err(e) => tracing::error!("error forking conversation: {e:#}"), - } - } - - /// Thin wrapper around ThreadManager::fork_thread. - async fn perform_fork( - &self, - path: PathBuf, - nth_user_message: usize, - cfg: codex_core::config::Config, - ) -> codex_core::error::Result { - self.server.fork_thread(nth_user_message, cfg, path).await - } - - /// Install a forked thread into the ChatWidget and update UI to reflect selection. - fn install_forked_conversation( - &mut self, - tui: &mut tui::Tui, - cfg: codex_core::config::Config, - new_conv: codex_core::NewThread, - nth_user_message: usize, - prefill: &str, - ) { - let thread = new_conv.thread; - let session_configured = new_conv.session_configured; - let init = crate::chatwidget::ChatWidgetInit { - config: cfg, - model: self.current_model.clone(), - frame_requester: tui.frame_requester(), - app_event_tx: self.app_event_tx.clone(), - initial_prompt: None, - initial_images: Vec::new(), - enhanced_keys_supported: self.enhanced_keys_supported, - auth_manager: self.auth_manager.clone(), - models_manager: self.server.get_models_manager(), - feedback: self.feedback.clone(), - is_first_run: false, - }; - self.chat_widget = - crate::chatwidget::ChatWidget::new_from_existing(init, thread, session_configured); - // Trim transcript up to the selected user message and re-render it. - self.trim_transcript_for_backtrack(nth_user_message); + self.apply_backtrack_rollback(selection); self.render_transcript_once(tui); - if !prefill.is_empty() { - self.chat_widget.set_composer_text(prefill.to_string()); - } tui.frame_requester().schedule_frame(); } + fn backtrack_selection(&self, nth_user_message: usize) -> Option { + let base_id = self.backtrack.base_id?; + if self.chat_widget.thread_id() != Some(base_id) { + return None; + } + + let prefill = nth_user_position(&self.transcript_cells, nth_user_message) + .and_then(|idx| self.transcript_cells.get(idx)) + .and_then(|cell| cell.as_any().downcast_ref::()) + .map(|c| c.message.clone()) + .unwrap_or_default(); + + Some(BacktrackSelection { + nth_user_message, + prefill, + }) + } + /// Trim transcript_cells to preserve only content up to the selected user message. fn trim_transcript_for_backtrack(&mut self, nth_user_message: usize) { trim_transcript_cells_to_nth_user(&mut self.transcript_cells, nth_user_message); diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 9301be8278..e63a53487d 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -1,7 +1,6 @@ use std::path::PathBuf; use codex_common::approval_presets::ApprovalPreset; -use codex_core::protocol::ConversationPathResponseEvent; use codex_core::protocol::Event; use codex_core::protocol::RateLimitSnapshot; use codex_file_search::FileMatch; @@ -186,9 +185,6 @@ pub(crate) enum AppEvent { /// Re-open the approval presets popup. OpenApprovalsPopup, - /// Forwarded conversation history snapshot from the current conversation. - ConversationHistory(ConversationPathResponseEvent), - /// Open the branch picker option from the review popup. OpenReviewBranchPicker(PathBuf), diff --git a/codex-rs/tui/src/bottom_pane/AGENTS.md b/codex-rs/tui/src/bottom_pane/AGENTS.md new file mode 100644 index 0000000000..32d2037c13 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/AGENTS.md @@ -0,0 +1,15 @@ +# TUI bottom pane (state machines) + +When changing the paste-burst or chat-composer state machines in this folder, keep the docs in sync: + +- Update the relevant module docs (`chat_composer.rs` and/or `paste_burst.rs`) so they remain a + readable, top-down explanation of the current behavior. +- Update the narrative doc `docs/tui-chat-composer.md` whenever behavior/assumptions change (Enter + handling, retro-capture, flush/clear rules, `disable_paste_burst`, non-ASCII/IME handling). +- Keep `codex-rs/tui` and `codex-rs/tui2` implementations/docstrings aligned unless the divergence + is intentional and documented. + +Practical check: + +- After edits, sanity-check that docs mention only APIs/behavior that exist in code (especially the + Enter/newline paths and `disable_paste_burst` semantics). diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 2e7e2241b9..7b8a8f215f 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -1,3 +1,62 @@ +//! The chat composer is the bottom-pane text input state machine. +//! +//! It is responsible for: +//! +//! - Editing the input buffer (a [`TextArea`]), including placeholder "elements" for attachments. +//! - Routing keys to the active popup (slash commands, file search, skill mentions). +//! - Handling submit vs newline on Enter. +//! - Turning raw key streams into explicit paste operations on platforms where terminals +//! don't provide reliable bracketed paste (notably Windows). +//! +//! # Key Event Routing +//! +//! Most key handling goes through [`ChatComposer::handle_key_event`], which dispatches to a +//! popup-specific handler if a popup is visible and otherwise to +//! [`ChatComposer::handle_key_event_without_popup`]. After every handled key, we call +//! [`ChatComposer::sync_popups`] so UI state follows the latest buffer/cursor. +//! +//! # Non-bracketed Paste Bursts +//! +//! On some terminals (especially on Windows), pastes arrive as a rapid sequence of +//! `KeyCode::Char` and `KeyCode::Enter` key events instead of a single paste event. +//! +//! To avoid misinterpreting these bursts as real typing (and to prevent transient UI effects like +//! shortcut overlays toggling on a pasted `?`), we feed "plain" character events into +//! [`PasteBurst`](super::paste_burst::PasteBurst), which buffers bursts and later flushes them +//! through [`ChatComposer::handle_paste`]. +//! +//! The burst detector intentionally treats ASCII and non-ASCII differently: +//! +//! - ASCII: we briefly hold the first fast char (flicker suppression) until we know whether the +//! stream is paste-like. +//! - non-ASCII: we do not hold the first char (IME input would feel dropped), but we still allow +//! burst detection for actual paste streams. +//! +//! The burst detector can also be disabled (`disable_paste_burst`), which bypasses the state +//! machine and treats the key stream as normal typing. +//! +//! For the detailed burst state machine, see `codex-rs/tui/src/bottom_pane/paste_burst.rs`. +//! For a narrative overview of the combined state machine, see `docs/tui-chat-composer.md`. +//! +//! # PasteBurst Integration Points +//! +//! The burst detector is consulted in a few specific places: +//! +//! - [`ChatComposer::handle_input_basic`]: flushes any due burst first, then intercepts plain char +//! input to either buffer it or insert normally. +//! - [`ChatComposer::handle_non_ascii_char`]: handles the non-ASCII/IME path without holding the +//! first char, while still allowing paste detection via retro-capture. +//! - [`ChatComposer::flush_paste_burst_if_due`]/[`ChatComposer::handle_paste_burst_flush`]: called +//! from UI ticks to turn a pending burst into either an explicit paste (`handle_paste`) or a +//! normal typed character. +//! +//! # Input Disabled Mode +//! +//! The composer can be temporarily read-only (`input_enabled = false`). In that mode it ignores +//! edits and renders a placeholder prompt instead of the editable textarea. This is part of the +//! overall state machine, since it affects which transitions are even possible from a given UI +//! state. + use crate::key_hint::has_ctrl_or_alt; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -80,6 +139,7 @@ const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000; #[derive(Debug, PartialEq)] pub enum InputResult { Submitted(String), + Queued(String), Command(SlashCommand), CommandWithArgs(SlashCommand, String), None, @@ -121,7 +181,7 @@ pub(crate) struct ChatComposer { /// When false, the composer is temporarily read-only (e.g. during sandbox setup). input_enabled: bool, input_disabled_placeholder: Option, - // Non-bracketed paste burst tracker. + /// Non-bracketed paste burst tracker (see `bottom_pane/paste_burst.rs`). paste_burst: PasteBurst, // When true, disables paste-burst logic and inserts characters immediately. disable_paste_burst: bool, @@ -132,6 +192,7 @@ pub(crate) struct ChatComposer { context_window_used_tokens: Option, skills: Option>, dismissed_skill_popup_token: Option, + steer_enabled: bool, } /// Popup state – at most one can be visible at any time. @@ -182,6 +243,7 @@ impl ChatComposer { context_window_used_tokens: None, skills: None, dismissed_skill_popup_token: None, + steer_enabled: false, }; // Apply configuration via the setter to keep side-effects centralized. this.set_disable_paste_burst(disable_paste_burst); @@ -192,6 +254,10 @@ impl ChatComposer { self.skills = skills; } + pub fn set_steer_enabled(&mut self, enabled: bool) { + self.steer_enabled = enabled; + } + fn layout_areas(&self, area: Rect) -> [Rect; 3] { let footer_props = self.footer_props(); let footer_hint_height = self @@ -250,6 +316,24 @@ impl ChatComposer { true } + /// Integrate pasted text into the composer. + /// + /// Acts as the only place where paste text is integrated, both for: + /// + /// - Real/explicit paste events surfaced by the terminal, and + /// - Non-bracketed "paste bursts" that [`PasteBurst`](super::paste_burst::PasteBurst) buffers + /// and later flushes here. + /// + /// Behavior: + /// + /// - If the paste is larger than `LARGE_PASTE_CHAR_THRESHOLD` chars, inserts a placeholder + /// element (expanded on submit) and stores the full text in `pending_pastes`. + /// - Otherwise, if the paste looks like an image path, attaches the image and inserts a + /// trailing space so the user can keep typing naturally. + /// - Otherwise, inserts the pasted text directly into the textarea. + /// + /// In all cases, clears any paste-burst Enter suppression state so a real paste cannot affect + /// the next user Enter key, then syncs popup state. pub fn handle_paste(&mut self, pasted: String) -> bool { let char_count = pasted.chars().count(); if char_count > LARGE_PASTE_CHAR_THRESHOLD { @@ -290,6 +374,16 @@ impl ChatComposer { } } + /// Enable or disable paste-burst handling. + /// + /// `disable_paste_burst` is an escape hatch for terminals/platforms where the burst heuristic + /// is unwanted or has already been handled elsewhere. + /// + /// When enabling the flag we clear the burst classification window so subsequent input cannot + /// be incorrectly grouped into a previous burst. + /// + /// This does not flush any in-progress buffer; callers should avoid toggling this mid-burst + /// (or should flush first). pub(crate) fn set_disable_paste_burst(&mut self, disabled: bool) { let was_disabled = self.disable_paste_burst; self.disable_paste_burst = disabled; @@ -410,7 +504,7 @@ impl ChatComposer { self.textarea.text().to_string() } - /// Attempt to start a burst by retro-capturing recent chars before the cursor. + /// Insert an attachment placeholder and track it for the next submission. pub fn attach_image(&mut self, path: PathBuf) { let image_number = self.attached_images.len() + 1; let placeholder = local_image_label_text(image_number); @@ -426,14 +520,31 @@ impl ChatComposer { images.into_iter().map(|img| img.path).collect() } + /// Flushes any due paste-burst state. + /// + /// Call this from a UI tick to turn paste-burst transient state into explicit textarea edits: + /// + /// - If a burst times out, flush it via `handle_paste(String)`. + /// - If only the first ASCII char was held (flicker suppression) and no burst followed, emit it + /// as normal typed input. + /// + /// This also allows a single "held" ASCII char to render even when it turns out not to be part + /// of a paste burst. pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool { self.handle_paste_burst_flush(Instant::now()) } + /// Returns whether the composer is currently in any paste-burst related transient state. + /// + /// This includes actively buffering, having a non-empty burst buffer, or holding the first + /// ASCII char for flicker suppression. pub(crate) fn is_in_paste_burst(&self) -> bool { self.paste_burst.is_active() } + /// Returns a delay that reliably exceeds the paste-burst timing threshold. + /// + /// Use this in tests to avoid boundary flakiness around the `PasteBurst` timeout. pub(crate) fn recommended_paste_flush_delay() -> Duration { PasteBurst::recommended_flush_delay() } @@ -672,6 +783,20 @@ impl ChatComposer { p } + /// Handle non-ASCII character input (often IME) while still supporting paste-burst detection. + /// + /// This handler exists because non-ASCII input often comes from IMEs, where characters can + /// legitimately arrive in short bursts that should **not** be treated as paste. + /// + /// The key differences from the ASCII path: + /// + /// - We never hold the first character (`PasteBurst::on_plain_char_no_hold`), because holding a + /// non-ASCII char can feel like dropped input. + /// - If a burst is detected, we may need to retroactively remove already-inserted text before + /// the cursor and move it into the paste buffer (see `PasteBurst::decide_begin_buffer`). + /// + /// Because this path mixes "insert immediately" with "maybe retro-grab later", it must clamp + /// the cursor to a UTF-8 char boundary before slicing `textarea.text()`. #[inline] fn handle_non_ascii_char(&mut self, input: KeyEvent) -> (InputResult, bool) { if let KeyEvent { @@ -698,12 +823,13 @@ impl ChatComposer { return (InputResult::None, true); } CharDecision::BeginBuffer { retro_chars } => { + // For non-ASCII we inserted prior chars immediately, so if this turns out + // to be paste-like we need to retroactively grab & remove the already- + // inserted prefix from the textarea before buffering the burst. let cur = self.textarea.cursor(); let txt = self.textarea.text(); let safe_cur = Self::clamp_to_char_boundary(txt, cur); let before = &txt[..safe_cur]; - // If decision is to buffer, seed the paste burst buffer with the grabbed chars + new. - // Otherwise, fall through to normal insertion below. if let Some(grab) = self.paste_burst .decide_begin_buffer(now, before, retro_chars as usize) @@ -715,6 +841,8 @@ impl ChatComposer { self.paste_burst.append_char_to_buffer(ch, now); return (InputResult::None, true); } + // If decide_begin_buffer opted not to start buffering, + // fall through to normal insertion below. } _ => unreachable!("on_plain_char_no_hold returned unexpected variant"), } @@ -1140,6 +1268,199 @@ impl ChatComposer { self.textarea.set_cursor(new_cursor); } + /// Prepare text for submission/queuing. Returns None if submission should be suppressed. + fn prepare_submission_text(&mut self) -> Option { + // If we have pending placeholder pastes, replace them in the textarea text + // and continue to the normal submission flow to handle slash commands. + if !self.pending_pastes.is_empty() { + let mut text = self.textarea.text().to_string(); + for (placeholder, actual) in &self.pending_pastes { + if text.contains(placeholder) { + text = text.replace(placeholder, actual); + } + } + self.textarea.set_text(&text); + self.pending_pastes.clear(); + } + + let mut text = self.textarea.text().to_string(); + let original_input = text.clone(); + let input_starts_with_space = original_input.starts_with(' '); + self.textarea.set_text(""); + + // Replace all pending pastes in the text + for (placeholder, actual) in &self.pending_pastes { + if text.contains(placeholder) { + text = text.replace(placeholder, actual); + } + } + self.pending_pastes.clear(); + + // If there is neither text nor attachments, suppress submission entirely. + let has_attachments = !self.attached_images.is_empty(); + text = text.trim().to_string(); + + if let Some((name, _rest)) = parse_slash_name(&text) { + let treat_as_plain_text = input_starts_with_space || name.contains('/'); + if !treat_as_plain_text { + let is_builtin = built_in_slash_commands() + .into_iter() + .filter(|(_, cmd)| { + windows_degraded_sandbox_active() || *cmd != SlashCommand::ElevateSandbox + }) + .any(|(command_name, _)| command_name == name); + let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:"); + let is_known_prompt = name + .strip_prefix(&prompt_prefix) + .map(|prompt_name| { + self.custom_prompts + .iter() + .any(|prompt| prompt.name == prompt_name) + }) + .unwrap_or(false); + if !is_builtin && !is_known_prompt { + let message = format!( + r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."# + ); + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_info_event(message, None), + ))); + self.textarea.set_text(&original_input); + self.textarea.set_cursor(original_input.len()); + return None; + } + } + } + + let expanded_prompt = match expand_custom_prompt(&text, &self.custom_prompts) { + Ok(expanded) => expanded, + Err(err) => { + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_error_event(err.user_message()), + ))); + self.textarea.set_text(&original_input); + self.textarea.set_cursor(original_input.len()); + return None; + } + }; + if let Some(expanded) = expanded_prompt { + text = expanded; + } + if text.is_empty() && !has_attachments { + return None; + } + if !text.is_empty() { + self.history.record_local_submission(&text); + } + Some(text) + } + + /// Common logic for handling message submission/queuing. + /// Returns the appropriate InputResult based on `should_queue`. + fn handle_submission(&mut self, should_queue: bool) -> (InputResult, bool) { + // If the first line is a bare built-in slash command (no args), + // dispatch it even when the slash popup isn't visible. This preserves + // the workflow: type a prefix ("/di"), press Tab to complete to + // "/diff ", then press Enter/Ctrl+Shift+Q to run it. Tab moves the cursor beyond + // the '/name' token and our caret-based heuristic hides the popup, + // but Enter/Ctrl+Shift+Q should still dispatch the command rather than submit + // literal text. + if let Some(result) = self.try_dispatch_bare_slash_command() { + return (result, true); + } + + // If we're in a paste-like burst capture, treat Enter/Ctrl+Shift+Q as part of the burst + // and accumulate it rather than submitting or inserting immediately. + // Do not treat as paste inside a slash-command context. + let in_slash_context = matches!(self.active_popup, ActivePopup::Command(_)) + || self + .textarea + .text() + .lines() + .next() + .unwrap_or("") + .starts_with('/'); + if self.paste_burst.is_active() && !in_slash_context { + let now = Instant::now(); + if self.paste_burst.append_newline_if_active(now) { + return (InputResult::None, true); + } + } + + // During a paste-like burst, treat Enter/Ctrl+Shift+Q as a newline instead of submit. + let now = Instant::now(); + if self + .paste_burst + .newline_should_insert_instead_of_submit(now) + && !in_slash_context + { + self.textarea.insert_str("\n"); + self.paste_burst.extend_window(now); + return (InputResult::None, true); + } + + let original_input = self.textarea.text().to_string(); + if let Some(result) = self.try_dispatch_slash_command_with_args() { + return (result, true); + } + + if let Some(text) = self.prepare_submission_text() { + if should_queue { + (InputResult::Queued(text), true) + } else { + // Do not clear attached_images here; ChatWidget drains them via take_recent_submission_images(). + (InputResult::Submitted(text), true) + } + } else { + // Restore text if submission was suppressed + self.textarea.set_text(&original_input); + (InputResult::None, true) + } + } + + /// Check if the first line is a bare slash command (no args) and dispatch it. + /// Returns Some(InputResult) if a command was dispatched, None otherwise. + fn try_dispatch_bare_slash_command(&mut self) -> Option { + let first_line = self.textarea.text().lines().next().unwrap_or(""); + if let Some((name, rest)) = parse_slash_name(first_line) + && rest.is_empty() + && let Some((_n, cmd)) = built_in_slash_commands() + .into_iter() + .filter(|(_, cmd)| { + windows_degraded_sandbox_active() || *cmd != SlashCommand::ElevateSandbox + }) + .find(|(n, _)| *n == name) + { + self.textarea.set_text(""); + Some(InputResult::Command(cmd)) + } else { + None + } + } + + /// Check if the input is a slash command with args (e.g., /review args) and dispatch it. + /// Returns Some(InputResult) if a command was dispatched, None otherwise. + fn try_dispatch_slash_command_with_args(&mut self) -> Option { + let original_input = self.textarea.text().to_string(); + let input_starts_with_space = original_input.starts_with(' '); + + if !input_starts_with_space { + let text = self.textarea.text().to_string(); + if let Some((name, rest)) = parse_slash_name(&text) + && !rest.is_empty() + && !name.contains('/') + && let Some((_n, cmd)) = built_in_slash_commands() + .into_iter() + .find(|(command_name, _)| *command_name == name) + && cmd == SlashCommand::Review + { + self.textarea.set_text(""); + return Some(InputResult::CommandWithArgs(cmd, rest.to_string())); + } + } + None + } + /// Handle key event when no popup is visible. fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { if self.handle_shortcut_overlay_key(&key_event) { @@ -1198,161 +1519,32 @@ impl ChatComposer { } self.handle_input_basic(key_event) } + KeyEvent { + code: KeyCode::Tab, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + .. + } => self.handle_submission(true), KeyEvent { code: KeyCode::Enter, modifiers: KeyModifiers::NONE, .. } => { - // If the first line is a bare built-in slash command (no args), - // dispatch it even when the slash popup isn't visible. This preserves - // the workflow: type a prefix ("/di"), press Tab to complete to - // "/diff ", then press Enter to run it. Tab moves the cursor beyond - // the '/name' token and our caret-based heuristic hides the popup, - // but Enter should still dispatch the command rather than submit - // literal text. - let first_line = self.textarea.text().lines().next().unwrap_or(""); - if let Some((name, rest)) = parse_slash_name(first_line) - && rest.is_empty() - && let Some((_n, cmd)) = built_in_slash_commands() - .into_iter() - .filter(|(_, cmd)| { - windows_degraded_sandbox_active() - || *cmd != SlashCommand::ElevateSandbox - }) - .find(|(n, _)| *n == name) - { - self.textarea.set_text(""); - return (InputResult::Command(cmd), true); - } - // If we're in a paste-like burst capture, treat Enter as part of the burst - // and accumulate it rather than submitting or inserting immediately. - // Do not treat Enter as paste inside a slash-command context. - let in_slash_context = matches!(self.active_popup, ActivePopup::Command(_)) - || self - .textarea - .text() - .lines() - .next() - .unwrap_or("") - .starts_with('/'); - if self.paste_burst.is_active() && !in_slash_context { - let now = Instant::now(); - if self.paste_burst.append_newline_if_active(now) { - return (InputResult::None, true); - } - } - // If we have pending placeholder pastes, replace them in the textarea text - // and continue to the normal submission flow to handle slash commands. - if !self.pending_pastes.is_empty() { - let mut text = self.textarea.text().to_string(); - for (placeholder, actual) in &self.pending_pastes { - if text.contains(placeholder) { - text = text.replace(placeholder, actual); - } - } - self.textarea.set_text(&text); - self.pending_pastes.clear(); - } - - // During a paste-like burst, treat Enter as a newline instead of submit. - let now = Instant::now(); - if self - .paste_burst - .newline_should_insert_instead_of_submit(now) - && !in_slash_context - { - self.textarea.insert_str("\n"); - self.paste_burst.extend_window(now); - return (InputResult::None, true); - } - let mut text = self.textarea.text().to_string(); - let original_input = text.clone(); - let input_starts_with_space = original_input.starts_with(' '); - self.textarea.set_text(""); - - // Replace all pending pastes in the text - for (placeholder, actual) in &self.pending_pastes { - if text.contains(placeholder) { - text = text.replace(placeholder, actual); - } - } - self.pending_pastes.clear(); - - // If there is neither text nor attachments, suppress submission entirely. - let has_attachments = !self.attached_images.is_empty(); - text = text.trim().to_string(); - if let Some((name, _rest)) = parse_slash_name(&text) { - let treat_as_plain_text = input_starts_with_space || name.contains('/'); - if !treat_as_plain_text { - let is_builtin = built_in_slash_commands() - .into_iter() - .filter(|(_, cmd)| { - windows_degraded_sandbox_active() - || *cmd != SlashCommand::ElevateSandbox - }) - .any(|(command_name, _)| command_name == name); - let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:"); - let is_known_prompt = name - .strip_prefix(&prompt_prefix) - .map(|prompt_name| { - self.custom_prompts - .iter() - .any(|prompt| prompt.name == prompt_name) - }) - .unwrap_or(false); - if !is_builtin && !is_known_prompt { - let message = format!( - r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."# - ); - self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::new_info_event(message, None), - ))); - self.textarea.set_text(&original_input); - self.textarea.set_cursor(original_input.len()); - return (InputResult::None, true); - } - } - } - - if !input_starts_with_space - && let Some((name, rest)) = parse_slash_name(&text) - && !rest.is_empty() - && !name.contains('/') - && let Some((_n, cmd)) = built_in_slash_commands() - .into_iter() - .find(|(command_name, _)| *command_name == name) - && cmd == SlashCommand::Review - { - return (InputResult::CommandWithArgs(cmd, rest.to_string()), true); - } - - let expanded_prompt = match expand_custom_prompt(&text, &self.custom_prompts) { - Ok(expanded) => expanded, - Err(err) => { - self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::new_error_event(err.user_message()), - ))); - self.textarea.set_text(&original_input); - self.textarea.set_cursor(original_input.len()); - return (InputResult::None, true); - } - }; - if let Some(expanded) = expanded_prompt { - text = expanded; - } - if text.is_empty() && !has_attachments { - return (InputResult::None, true); - } - if !text.is_empty() { - self.history.record_local_submission(&text); - } - // Do not clear attached_images here; ChatWidget drains them via take_recent_submission_images(). - (InputResult::Submitted(text), true) + let should_queue = !self.steer_enabled; + self.handle_submission(should_queue) } input => self.handle_input_basic(input), } } + /// Applies any due `PasteBurst` flush at time `now`. + /// + /// Converts [`PasteBurst::flush_if_due`] results into concrete textarea mutations. + /// + /// Callers: + /// + /// - UI ticks via [`ChatComposer::flush_paste_burst_if_due`], so held first-chars can render. + /// - Input handling via [`ChatComposer::handle_input_basic`], so a due burst does not lag. fn handle_paste_burst_flush(&mut self, now: Instant) -> bool { match self.paste_burst.flush_if_due(now) { FlushResult::Paste(pasted) => { @@ -1370,7 +1562,20 @@ impl ChatComposer { } } - /// Handle generic Input events that modify the textarea content. + /// Handles keys that mutate the textarea, including paste-burst detection. + /// + /// Acts as the lowest-level keypath for keys that mutate the textarea. It is also where plain + /// character streams are converted into explicit paste operations on terminals that do not + /// reliably provide bracketed paste. + /// + /// Ordering is important: + /// + /// - Always flush any *due* paste burst first so buffered text does not lag behind unrelated + /// edits. + /// - Then handle the incoming key, intercepting only "plain" (no Ctrl/Alt) char input. + /// - For non-plain keys, flush via `flush_before_modified_input()` before applying the key; + /// otherwise `clear_window_after_non_char()` can leave buffered text waiting without a + /// timestamp to time out against. fn handle_input_basic(&mut self, input: KeyEvent) -> (InputResult, bool) { // If we have a buffered non-bracketed paste burst and enough time has // elapsed since the last char, flush it before handling a new input. @@ -1390,6 +1595,10 @@ impl ChatComposer { } // Intercept plain Char inputs to optionally accumulate into a burst buffer. + // + // This is intentionally limited to "plain" (no Ctrl/Alt) chars so shortcuts keep their + // normal semantics, and so we can aggressively flush/clear any burst state when non-char + // keys are pressed. if let KeyEvent { code: KeyCode::Char(ch), modifiers, @@ -1578,6 +1787,7 @@ impl ChatComposer { esc_backtrack_hint: self.esc_backtrack_hint, use_shift_enter_hint: self.use_shift_enter_hint, is_task_running: self.is_task_running, + steer_enabled: self.steer_enabled, context_window_percent: self.context_window_percent, context_window_used_tokens: self.context_window_used_tokens, } @@ -2208,6 +2418,14 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); composer.set_text_content("draft text".to_string()); assert_eq!(composer.clear_for_ctrl_c(), Some("draft text".to_string())); @@ -2234,6 +2452,13 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); let (result, needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); @@ -2274,6 +2499,10 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); // Force an active paste burst so this test doesn't depend on tight timing. composer @@ -2307,6 +2536,10 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); assert_eq!(composer.footer_mode, FooterMode::ShortcutOverlay); @@ -2481,6 +2714,10 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)); assert!(composer.is_in_paste_burst()); @@ -2510,6 +2747,10 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('あ'), KeyModifiers::NONE)); @@ -2581,6 +2822,9 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); // Force an active burst so this test doesn't depend on tight timing. composer @@ -2620,6 +2864,9 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); let needs_redraw = composer.handle_paste("hello".to_string()); assert!(needs_redraw); @@ -2649,6 +2896,9 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); // Ensure composer is empty and press Enter. assert!(composer.textarea.text().is_empty()); @@ -2676,6 +2926,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); let large = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 10); let needs_redraw = composer.handle_paste(large.clone()); @@ -2711,6 +2962,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); composer.handle_paste(large); assert_eq!(composer.pending_pastes.len(), 1); @@ -2780,6 +3032,18 @@ mod tests { } } + #[test] + fn image_placeholder_snapshots() { + snapshot_composer_state("image_placeholder_single", false, |composer| { + composer.attach_image(PathBuf::from("/tmp/image1.png")); + }); + + snapshot_composer_state("image_placeholder_multiple", false, |composer| { + composer.attach_image(PathBuf::from("/tmp/image1.png")); + composer.attach_image(PathBuf::from("/tmp/image2.png")); + }); + } + #[test] fn slash_popup_model_first_for_mo_ui() { use ratatui::Terminal; @@ -2795,6 +3059,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); // Type "/mo" humanlike so paste-burst doesn’t interfere. type_chars_humanlike(&mut composer, &['/', 'm', 'o']); @@ -2823,6 +3088,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); type_chars_humanlike(&mut composer, &['/', 'm', 'o']); match &composer.active_popup { @@ -2854,6 +3120,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); // Type "/res" humanlike so paste-burst doesn’t interfere. type_chars_humanlike(&mut composer, &['/', 'r', 'e', 's']); @@ -2947,6 +3214,9 @@ mod tests { InputResult::Submitted(text) => { panic!("expected command dispatch, but composer submitted literal text: {text}") } + InputResult::Queued(_) => { + panic!("expected command dispatch, but composer queued literal text") + } InputResult::None => panic!("expected Command result for '/init'"), } assert!(composer.textarea.is_empty(), "composer should be cleared"); @@ -3023,6 +3293,9 @@ mod tests { InputResult::Submitted(text) => { panic!("expected command dispatch after Tab completion, got literal submit: {text}") } + InputResult::Queued(_) => { + panic!("expected command dispatch after Tab completion, got literal queue") + } InputResult::None => panic!("expected Command result for '/diff'"), } assert!(composer.textarea.is_empty()); @@ -3059,6 +3332,9 @@ mod tests { InputResult::Submitted(text) => { panic!("expected command dispatch, but composer submitted literal text: {text}") } + InputResult::Queued(_) => { + panic!("expected command dispatch, but composer queued literal text") + } InputResult::None => panic!("expected Command result for '/mention'"), } assert!(composer.textarea.is_empty(), "composer should be cleared"); @@ -3081,6 +3357,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); // Define test cases: (paste content, is_large) let test_cases = [ @@ -3357,6 +3634,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); let path = PathBuf::from("/tmp/image1.png"); composer.attach_image(path.clone()); composer.handle_paste(" hi".into()); @@ -3381,6 +3659,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); let path = PathBuf::from("/tmp/image2.png"); composer.attach_image(path.clone()); let (result, _) = @@ -3616,6 +3895,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); // Inject prompts as if received via event. composer.set_custom_prompts(vec![CustomPrompt { @@ -3652,6 +3932,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); composer.set_custom_prompts(vec![CustomPrompt { name: "my-prompt".to_string(), @@ -3686,6 +3967,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); composer.set_custom_prompts(vec![CustomPrompt { name: "my-prompt".to_string(), @@ -3724,6 +4006,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); // Create a custom prompt with positional args (no named args like $USER) composer.set_custom_prompts(vec![CustomPrompt { @@ -3788,6 +4071,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); composer .textarea @@ -3824,6 +4108,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); composer.textarea.set_text(" /this-looks-like-a-command"); @@ -3957,6 +4242,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); composer.set_custom_prompts(vec![CustomPrompt { name: "my-prompt".to_string(), @@ -3994,6 +4280,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); composer.set_custom_prompts(vec![CustomPrompt { name: "elegant".to_string(), @@ -4061,6 +4348,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); composer.set_custom_prompts(vec![CustomPrompt { name: "price".to_string(), @@ -4098,6 +4386,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); composer.set_custom_prompts(vec![CustomPrompt { name: "repeat".to_string(), diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 2e29d14d8b..3fb04c3935 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -20,6 +20,7 @@ pub(crate) struct FooterProps { pub(crate) esc_backtrack_hint: bool, pub(crate) use_shift_enter_hint: bool, pub(crate) is_task_running: bool, + pub(crate) steer_enabled: bool, pub(crate) context_window_percent: Option, pub(crate) context_window_used_tokens: Option, } @@ -110,10 +111,18 @@ fn footer_lines(props: FooterProps) -> Vec> { shortcut_overlay_lines(state) } FooterMode::EscHint => vec![esc_hint_line(props.esc_backtrack_hint)], - FooterMode::ContextOnly => vec![context_window_line( - props.context_window_percent, - props.context_window_used_tokens, - )], + FooterMode::ContextOnly => { + let mut line = context_window_line( + props.context_window_percent, + props.context_window_used_tokens, + ); + if props.is_task_running && props.steer_enabled { + line.push_span(" · ".dim()); + line.push_span(key_hint::plain(KeyCode::Tab)); + line.push_span(" to queue message".dim()); + } + vec![line] + } } } @@ -161,6 +170,7 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { let mut commands = Line::from(""); let mut shell_commands = Line::from(""); let mut newline = Line::from(""); + let mut queue_message_tab = Line::from(""); let mut file_paths = Line::from(""); let mut paste_image = Line::from(""); let mut external_editor = Line::from(""); @@ -174,6 +184,7 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { ShortcutId::Commands => commands = text, ShortcutId::ShellCommands => shell_commands = text, ShortcutId::InsertNewline => newline = text, + ShortcutId::QueueMessageTab => queue_message_tab = text, ShortcutId::FilePaths => file_paths = text, ShortcutId::PasteImage => paste_image = text, ShortcutId::ExternalEditor => external_editor = text, @@ -188,6 +199,7 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { commands, shell_commands, newline, + queue_message_tab, file_paths, paste_image, external_editor, @@ -266,6 +278,7 @@ enum ShortcutId { Commands, ShellCommands, InsertNewline, + QueueMessageTab, FilePaths, PasteImage, ExternalEditor, @@ -372,6 +385,15 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ prefix: "", label: " for newline", }, + ShortcutDescriptor { + id: ShortcutId::QueueMessageTab, + bindings: &[ShortcutBinding { + key: key_hint::plain(KeyCode::Tab), + condition: DisplayCondition::Always, + }], + prefix: "", + label: " to queue message", + }, ShortcutDescriptor { id: ShortcutId::FilePaths, bindings: &[ShortcutBinding { @@ -464,6 +486,7 @@ mod tests { esc_backtrack_hint: false, use_shift_enter_hint: false, is_task_running: false, + steer_enabled: false, context_window_percent: None, context_window_used_tokens: None, }, @@ -476,6 +499,7 @@ mod tests { esc_backtrack_hint: true, use_shift_enter_hint: true, is_task_running: false, + steer_enabled: false, context_window_percent: None, context_window_used_tokens: None, }, @@ -488,6 +512,7 @@ mod tests { esc_backtrack_hint: false, use_shift_enter_hint: false, is_task_running: false, + steer_enabled: false, context_window_percent: None, context_window_used_tokens: None, }, @@ -500,6 +525,7 @@ mod tests { esc_backtrack_hint: false, use_shift_enter_hint: false, is_task_running: true, + steer_enabled: false, context_window_percent: None, context_window_used_tokens: None, }, @@ -512,6 +538,7 @@ mod tests { esc_backtrack_hint: false, use_shift_enter_hint: false, is_task_running: false, + steer_enabled: false, context_window_percent: None, context_window_used_tokens: None, }, @@ -524,6 +551,7 @@ mod tests { esc_backtrack_hint: true, use_shift_enter_hint: false, is_task_running: false, + steer_enabled: false, context_window_percent: None, context_window_used_tokens: None, }, @@ -536,6 +564,7 @@ mod tests { esc_backtrack_hint: false, use_shift_enter_hint: false, is_task_running: true, + steer_enabled: false, context_window_percent: Some(72), context_window_used_tokens: None, }, @@ -548,9 +577,36 @@ mod tests { esc_backtrack_hint: false, use_shift_enter_hint: false, is_task_running: false, + steer_enabled: false, context_window_percent: None, context_window_used_tokens: Some(123_456), }, ); + + snapshot_footer( + "footer_context_only_queue_hint_disabled", + FooterProps { + mode: FooterMode::ContextOnly, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: true, + steer_enabled: false, + context_window_percent: None, + context_window_used_tokens: None, + }, + ); + + snapshot_footer( + "footer_context_only_queue_hint_enabled", + FooterProps { + mode: FooterMode::ContextOnly, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: true, + steer_enabled: true, + context_window_percent: None, + context_window_used_tokens: None, + }, + ); } } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index f795ea64f1..f505b0271e 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -145,6 +145,10 @@ impl BottomPane { self.request_redraw(); } + pub fn set_steer_enabled(&mut self, enabled: bool) { + self.composer.set_steer_enabled(enabled); + } + pub fn status_widget(&self) -> Option<&StatusIndicatorWidget> { self.status.as_ref() } diff --git a/codex-rs/tui/src/bottom_pane/paste_burst.rs b/codex-rs/tui/src/bottom_pane/paste_burst.rs index 96ed095b8f..ae0234f1a7 100644 --- a/codex-rs/tui/src/bottom_pane/paste_burst.rs +++ b/codex-rs/tui/src/bottom_pane/paste_burst.rs @@ -1,3 +1,150 @@ +//! Paste-burst detection for terminals without bracketed paste. +//! +//! On some platforms (notably Windows), pastes often arrive as a rapid stream of +//! `KeyCode::Char` and `KeyCode::Enter` key events rather than as a single "paste" event. +//! In that mode, the composer needs to: +//! +//! - Prevent transient UI side effects (e.g. toggles bound to `?`) from triggering on pasted text. +//! - Ensure Enter is treated as a newline *inside the paste*, not as "submit the message". +//! - Avoid flicker caused by inserting a typed prefix and then immediately reclassifying it as +//! paste once enough chars have arrived. +//! +//! This module provides the `PasteBurst` state machine. `ChatComposer` feeds it only "plain" +//! character events (no Ctrl/Alt) and uses its decisions to either: +//! +//! - briefly hold a first ASCII char (flicker suppression), +//! - buffer a burst as a single pasted string, or +//! - let input flow through as normal typing. +//! +//! For the higher-level view of how `PasteBurst` integrates with `ChatComposer`, see +//! `docs/tui-chat-composer.md`. +//! +//! # Call Pattern +//! +//! `PasteBurst` is a pure state machine: it never mutates the textarea directly. The caller feeds +//! it events and then applies the chosen action: +//! +//! - For each plain `KeyCode::Char`, call [`PasteBurst::on_plain_char`] (ASCII) or +//! [`PasteBurst::on_plain_char_no_hold`] (non-ASCII/IME). +//! - If the decision indicates buffering, the caller appends to `PasteBurst.buffer` via +//! [`PasteBurst::append_char_to_buffer`]. +//! - On a UI tick, call [`PasteBurst::flush_if_due`]. If it returns [`FlushResult::Typed`], insert +//! that char as normal typing. If it returns [`FlushResult::Paste`], treat the returned string as +//! an explicit paste. +//! - Before applying non-char input (arrow keys, Ctrl/Alt modifiers, etc.), use +//! [`PasteBurst::flush_before_modified_input`] to avoid leaving buffered text "stuck", and then +//! [`PasteBurst::clear_window_after_non_char`] so subsequent typing does not get grouped into a +//! previous burst. +//! +//! # State Variables +//! +//! This state machine is encoded in a few fields with slightly different meanings: +//! +//! - `active`: true while we are still *actively* accepting characters into the current burst. +//! - `buffer`: accumulated burst text that will eventually flush as a single `Paste(String)`. +//! A non-empty buffer is treated as "in burst context" even if `active` has been cleared. +//! - `pending_first_char`: a single held ASCII char used for flicker suppression. The caller must +//! not render this char until it either becomes part of a burst (`BeginBufferFromPending`) or +//! flushes as a normal typed char (`FlushResult::Typed`). +//! - `last_plain_char_time`/`consecutive_plain_char_burst`: the timing/count heuristic for +//! "paste-like" streams. +//! - `burst_window_until`: the Enter suppression window ("Enter inserts newline") that outlives the +//! buffer itself. +//! +//! # Timing Model +//! +//! There are two timeouts: +//! +//! - `PASTE_BURST_CHAR_INTERVAL`: maximum delay between consecutive "plain" chars for them to be +//! considered part of a single burst. It also bounds how long `pending_first_char` is held. +//! - `PASTE_BURST_ACTIVE_IDLE_TIMEOUT`: once buffering is active, how long to wait after the last +//! char before flushing the accumulated buffer as a paste. +//! +//! `flush_if_due()` intentionally uses `>` (not `>=`) when comparing elapsed time, so tests and UI +//! ticks should cross the threshold by at least 1ms (see `recommended_flush_delay()`). +//! +//! # Retro Capture Details +//! +//! Retro-capture exists to handle the case where we initially inserted characters as "normal +//! typing", but later decide that the stream is paste-like. When that happens, we retroactively +//! remove a prefix of already-inserted text from the textarea and move it into the burst buffer so +//! the eventual `handle_paste(...)` sees a contiguous pasted string. +//! +//! Retro-capture mostly matters on paths that do *not* hold the first character (non-ASCII/IME +//! input, and retro-grab scenarios). The ASCII path usually prefers +//! `RetainFirstChar -> BeginBufferFromPending`, which avoids needing retro-capture at all. +//! +//! Retro-capture is expressed in terms of characters, not bytes: +//! +//! - `CharDecision::BeginBuffer { retro_chars }` uses `retro_chars` as a character count. +//! - `decide_begin_buffer(now, before_cursor, retro_chars)` turns that into a UTF-8 byte range by +//! calling `retro_start_index()`. +//! - `RetroGrab.start_byte` is a byte index into the `before_cursor` slice; callers must clamp the +//! cursor to a char boundary before slicing so `start_byte..cursor` is always valid UTF-8. +//! +//! # Clearing vs Flushing +//! +//! There are two ways callers end burst handling, and they are not interchangeable: +//! +//! - `flush_before_modified_input()` returns the buffered text (and/or a pending first ASCII char) +//! so the caller can apply it through the normal paste path before handling an unrelated input. +//! - `clear_window_after_non_char()` clears the *classification window* so subsequent typing does +//! not get grouped into the previous burst. It assumes the caller has already flushed any buffer +//! because it clears `last_plain_char_time`, which means `flush_if_due()` will not flush a +//! non-empty buffer until another plain char updates the timestamp. +//! +//! # States (Conceptually) +//! +//! - **Idle**: no buffered text, no pending char. +//! - **Pending first char**: `pending_first_char` holds one ASCII char for up to +//! `PASTE_BURST_CHAR_INTERVAL` while we wait to see if a burst follows. +//! - **Active buffer**: `active`/`buffer` holds paste-like content until it times out and flushes. +//! - **Enter suppress window**: `burst_window_until` keeps Enter treated as newline briefly after +//! burst activity so multiline pastes stay grouped. +//! +//! # ASCII vs Non-ASCII +//! +//! - [`PasteBurst::on_plain_char`] may return [`CharDecision::RetainFirstChar`] to hold the first +//! ASCII char and avoid flicker. +//! - [`PasteBurst::on_plain_char_no_hold`] never holds (used for IME/non-ASCII paths), since +//! holding a non-ASCII character can feel like dropped input. +//! +//! # Contract With `ChatComposer` +//! +//! `PasteBurst` does not mutate the UI text buffer on its own. The caller (`ChatComposer`) must +//! interpret decisions and apply the corresponding UI edits: +//! +//! - For each plain ASCII `KeyCode::Char`, call [`PasteBurst::on_plain_char`]. +//! - [`CharDecision::RetainFirstChar`]: do **not** insert the char into the textarea yet. +//! - [`CharDecision::BeginBufferFromPending`]: call [`PasteBurst::append_char_to_buffer`] for the +//! current char (the previously-held char is already in the burst buffer). +//! - [`CharDecision::BeginBuffer { retro_chars }`]: consider retro-capturing the already-inserted +//! prefix by calling [`PasteBurst::decide_begin_buffer`]. If it returns `Some`, remove the +//! returned `start_byte..cursor` range from the textarea and then call +//! [`PasteBurst::append_char_to_buffer`] for the current char. If it returns `None`, fall back +//! to normal insertion. +//! - [`CharDecision::BufferAppend`]: call [`PasteBurst::append_char_to_buffer`]. +//! +//! - For each plain non-ASCII `KeyCode::Char`, call [`PasteBurst::on_plain_char_no_hold`] and then: +//! - If it returns `Some(CharDecision::BufferAppend)`, call +//! [`PasteBurst::append_char_to_buffer`]. +//! - If it returns `Some(CharDecision::BeginBuffer { retro_chars })`, call +//! [`PasteBurst::decide_begin_buffer`] as above (and if buffering starts, remove the grabbed +//! prefix from the textarea and then append the current char to the buffer). +//! - If it returns `None`, insert normally. +//! +//! - Before applying non-char input (or any input that should not join a burst), call +//! [`PasteBurst::flush_before_modified_input`] and pass the returned string (if any) through the +//! normal paste path. +//! +//! - Periodically (e.g. on a UI tick), call [`PasteBurst::flush_if_due`]. +//! - [`FlushResult::Typed`]: insert that single char as normal typing. +//! - [`FlushResult::Paste`]: treat the returned string as an explicit paste. +//! +//! - When a non-plain key is pressed (Ctrl/Alt-modified input, arrows, etc.), callers should use +//! [`PasteBurst::clear_window_after_non_char`] to prevent the next keystroke from being +//! incorrectly grouped into a previous burst. + use std::time::Duration; use std::time::Instant; @@ -130,15 +277,15 @@ impl PasteBurst { self.last_plain_char_time = Some(now); } - /// Flush the buffered burst if the inter-key timeout has elapsed. + /// Flushes any buffered burst if the inter-key timeout has elapsed. /// - /// Returns Some(String) when either: - /// - We were actively buffering paste-like input and the buffer is now - /// emitted as a single pasted string; or - /// - We had saved a single fast first-char with no subsequent burst and we - /// now emit that char as normal typed input. + /// Returns: /// - /// Returns None if the timeout has not elapsed or there is nothing to flush. + /// - [`FlushResult::Paste`] when a paste burst was active and buffered text is emitted as one + /// pasted string. + /// - [`FlushResult::Typed`] when a single fast first ASCII char was being held (flicker + /// suppression) and no burst followed before the timeout elapsed. + /// - [`FlushResult::None`] when the timeout has not elapsed, or there is nothing to flush. pub fn flush_if_due(&mut self, now: Instant) -> FlushResult { let timeout = if self.is_active_internal() { PASTE_BURST_ACTIVE_IDLE_TIMEOUT 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 e25baa1112..8486a9ec6f 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 @@ -1,6 +1,5 @@ --- source: tui/src/bottom_pane/chat_composer.rs -assertion_line: 2151 expression: terminal.backend() --- " " @@ -11,8 +10,9 @@ expression: terminal.backend() " " " " " " -" / for commands ! for shell commands " -" shift + enter for newline @ for file paths " -" ctrl + v to paste images ctrl + g to edit in external editor " -" esc again to edit previous message ctrl + c to exit " -" ctrl + t to view transcript " +" / for commands ! for shell commands " +" shift + enter for newline tab to queue message " +" @ for file paths ctrl + v to paste images " +" ctrl + g to edit in external editor esc again to edit previous message " +" ctrl + c to exit " +" ctrl + t to view transcript " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__image_placeholder_multiple.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__image_placeholder_multiple.snap new file mode 100644 index 0000000000..3f1adf6291 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__image_placeholder_multiple.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +assertion_line: 2116 +expression: terminal.backend() +--- +" " +"› [Image #1][Image #2] " +" " +" " +" " +" " +" " +" " +" 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__image_placeholder_single.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__image_placeholder_single.snap new file mode 100644 index 0000000000..e46fa0a740 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__image_placeholder_single.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +assertion_line: 2116 +expression: terminal.backend() +--- +" " +"› [Image #1] " +" " +" " +" " +" " +" " +" " +" 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_context_only_queue_hint_disabled.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_context_only_queue_hint_disabled.snap new file mode 100644 index 0000000000..ce36b2ada8 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_context_only_queue_hint_disabled.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_context_only_queue_hint_enabled.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_context_only_queue_hint_enabled.snap new file mode 100644 index 0000000000..b9733866d7 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_context_only_queue_hint_enabled.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" 100% context left · tab to queue message " 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 6156a5b96a..c1f00d4437 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 @@ -1,10 +1,10 @@ --- source: tui/src/bottom_pane/footer.rs -assertion_line: 455 expression: terminal.backend() --- -" / for commands ! for shell commands " -" shift + enter for newline @ for file paths " -" ctrl + v to paste images ctrl + g to edit in external editor " -" esc again to edit previous message ctrl + c to exit " -" ctrl + t to view transcript " +" / for commands ! for shell commands " +" shift + enter for newline tab to queue message " +" @ for file paths ctrl + v to paste images " +" ctrl + g to edit in external editor esc again to edit previous message " +" ctrl + c to exit " +" ctrl + t to view transcript " diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index f2ed40758d..903ebe9f82 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -730,8 +730,8 @@ impl TextArea { /// Renames a single text element in-place, keeping it atomic. /// - /// This is intended for cases where the element payload is an identifier (e.g. a placeholder) - /// that must be updated without converting the element back into normal text. + /// Use this when the element payload is an identifier (e.g. a placeholder) that must be + /// updated without converting the element back into normal text. pub fn replace_element_payload(&mut self, old: &str, new: &str) -> bool { let Some(idx) = self .elements diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 08b6f3a8ba..d62329024c 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1,3 +1,20 @@ +//! The main Codex TUI chat surface. +//! +//! `ChatWidget` consumes protocol events, builds and updates history cells, and drives rendering +//! for both the main viewport and overlay UIs. +//! +//! The UI has both committed transcript cells (finalized `HistoryCell`s) and an in-flight active +//! cell (`ChatWidget.active_cell`) that can mutate in place while streaming (often representing a +//! coalesced exec/tool group). The transcript overlay (`Ctrl+T`) renders committed cells plus a +//! cached, render-only live tail derived from the current active cell so in-flight tool calls are +//! visible immediately. +//! +//! The transcript overlay is kept in sync by `App::overlay_forward_event`, which syncs a live tail +//! during draws using `active_cell_transcript_key()` and `active_cell_transcript_lines()`. The +//! cache key is designed to change when the active cell mutates in place or when its transcript +//! output is time-dependent so the overlay can refresh its cached tail without rebuilding it on +//! every draw. + use std::collections::HashMap; use std::collections::HashSet; use std::collections::VecDeque; @@ -318,6 +335,16 @@ pub(crate) struct ChatWidget { codex_op_tx: UnboundedSender, bottom_pane: BottomPane, active_cell: Option>, + /// Monotonic-ish counter used to invalidate transcript overlay caching. + /// + /// The transcript overlay appends a cached "live tail" for the current active cell. Most + /// active-cell updates are mutations of the *existing* cell (not a replacement), so pointer + /// identity alone is not a good cache key. + /// + /// Callers bump this whenever the active cell's transcript output could change without + /// flushing. It is intentionally allowed to wrap, which implies a rare one-time cache collision + /// where the overlay may briefly treat new tail content as already cached. + active_cell_revision: u64, config: Config, model: String, auth_manager: Arc, @@ -374,6 +401,30 @@ pub(crate) struct ChatWidget { external_editor_state: ExternalEditorState, } +/// Snapshot of active-cell state that affects transcript overlay rendering. +/// +/// The overlay keeps a cached "live tail" for the in-flight cell; this key lets +/// it cheaply decide when to recompute that tail as the active cell evolves. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct ActiveCellTranscriptKey { + /// Cache-busting revision for in-place updates. + /// + /// Many active cells are updated incrementally while streaming (for example when exec groups + /// add output or change status), and the transcript overlay caches its live tail, so this + /// revision gives a cheap way to say "same active cell, but its transcript output is different + /// now". Callers bump it on any mutation that can affect `HistoryCell::transcript_lines`. + pub(crate) revision: u64, + /// Whether the active cell continues the prior stream, which affects + /// spacing between transcript blocks. + pub(crate) is_stream_continuation: bool, + /// Optional animation tick for time-dependent transcript output. + /// + /// When this changes, the overlay recomputes the cached tail even if the revision and width + /// are unchanged, which is how shimmer/spinner visuals can animate in the overlay without any + /// underlying data change. + pub(crate) animation_tick: Option, +} + struct UserMessage { text: String, image_paths: Vec, @@ -903,7 +954,9 @@ impl ChatWidget { }) && wait_cell.matches(command_display.as_deref()) { // Same process still waiting; update command display if it shows up late. - wait_cell.update_command_display(command_display); + if wait_cell.update_command_display(command_display) { + self.bump_active_cell_revision(); + } self.request_redraw(); return; } @@ -924,6 +977,7 @@ impl ChatWidget { command_display, self.config.animations, ))); + self.bump_active_cell_revision(); self.request_redraw(); } else { if let Some(wait_cell) = self.active_cell.as_ref().and_then(|cell| { @@ -1228,6 +1282,9 @@ impl ChatWidget { cell.complete_call(&ev.call_id, output, ev.duration); if cell.should_flush() { self.flush_active_cell(); + } else { + self.bump_active_cell_revision(); + self.request_redraw(); } } } @@ -1344,6 +1401,7 @@ impl ChatWidget { ) { *cell = new_exec; + self.bump_active_cell_revision(); } else { self.flush_active_cell(); @@ -1355,6 +1413,7 @@ impl ChatWidget { interaction_input, self.config.animations, ))); + self.bump_active_cell_revision(); } self.request_redraw(); @@ -1368,6 +1427,7 @@ impl ChatWidget { ev.invocation, self.config.animations, ))); + self.bump_active_cell_revision(); self.request_redraw(); } pub(crate) fn handle_mcp_end_now(&mut self, ev: McpToolCallEndEvent) { @@ -1440,6 +1500,7 @@ impl ChatWidget { skills: None, }), active_cell: None, + active_cell_revision: 0, config, model: model.clone(), auth_manager, @@ -1482,6 +1543,9 @@ impl ChatWidget { }; widget.prefetch_rate_limits(); + widget + .bottom_pane + .set_steer_enabled(widget.config.features.enabled(Feature::Steer)); widget } @@ -1526,6 +1590,7 @@ impl ChatWidget { skills: None, }), active_cell: None, + active_cell_revision: 0, config, model: model.clone(), auth_manager, @@ -1568,6 +1633,9 @@ impl ChatWidget { }; widget.prefetch_rate_limits(); + widget + .bottom_pane + .set_steer_enabled(widget.config.features.enabled(Feature::Steer)); widget } @@ -1588,26 +1656,10 @@ impl ChatWidget { modifiers, kind: KeyEventKind::Press, .. - } if modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) - && c.eq_ignore_ascii_case(&'v') => + } if c.eq_ignore_ascii_case(&'v') + && modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => { - match paste_image_to_temp_png() { - Ok((path, info)) => { - tracing::debug!( - "pasted image size={}x{} format={}", - info.width, - info.height, - info.encoded_format.label() - ); - self.attach_image(path); - } - Err(err) => { - tracing::warn!("failed to paste image: {err}"); - self.add_to_history(history_cell::new_error_event(format!( - "Failed to paste image: {err}", - ))); - } - } + self.paste_image_from_clipboard(); return; } other if other.kind == KeyEventKind::Press => { @@ -1633,7 +1685,19 @@ impl ChatWidget { _ => { match self.bottom_pane.handle_key_event(key_event) { InputResult::Submitted(text) => { - // If a task is running, queue the user input to be sent after the turn completes. + // Enter always sends messages immediately (bypasses queue check) + // Clear any reasoning status header when submitting a new message + self.reasoning_buffer.clear(); + self.full_reasoning_buffer.clear(); + self.set_status_header(String::from("Working")); + let user_message = UserMessage { + text, + image_paths: self.bottom_pane.take_recent_submission_images(), + }; + self.submit_user_message(user_message); + } + InputResult::Queued(text) => { + // Tab queues the message if a task is running, otherwise submits immediately let user_message = UserMessage { text, image_paths: self.bottom_pane.take_recent_submission_images(), @@ -1658,6 +1722,32 @@ impl ChatWidget { self.request_redraw(); } + /// Attempt to attach an image from the system clipboard. + /// + /// This is a best-effort path used when we receive an empty paste event, + /// which some terminals emit when the clipboard contains non-text data + /// (like images). When the clipboard can't be read or no image exists, + /// surface a helpful follow-up so the user can retry with a file path. + fn paste_image_from_clipboard(&mut self) { + match paste_image_to_temp_png() { + Ok((path, info)) => { + tracing::debug!( + "pasted image size={}x{} format={}", + info.width, + info.height, + info.encoded_format.label() + ); + self.attach_image(path); + } + Err(err) => { + tracing::warn!("failed to paste image: {err}"); + self.add_to_history(history_cell::new_error_event(format!( + "Failed to paste image: {err}. Try saving the image to a file and pasting the file path instead.", + ))); + } + } + } + pub(crate) fn composer_text_with_pending(&self) -> String { self.bottom_pane.composer_text_with_pending() } @@ -1912,6 +2002,20 @@ impl ChatWidget { self.bottom_pane.handle_paste(text); } + /// Route paste events through image detection. + /// + /// Terminals vary in how they represent paste: some emit an empty paste + /// payload when the clipboard isn't text (common for image-only clipboard + /// contents). Treat the empty payload as a hint to attempt a clipboard + /// image read; otherwise, fall back to text handling. + pub(crate) fn handle_paste_event(&mut self, text: String) { + if text.is_empty() { + self.paste_image_from_clipboard(); + } else { + self.handle_paste(text); + } + } + // Returns true if caller should skip rendering this frame (a future frame is scheduled). pub(crate) fn handle_paste_burst_tick(&mut self, frame_requester: FrameRequester) -> bool { if self.bottom_pane.flush_paste_burst_if_due() { @@ -1973,7 +2077,7 @@ impl ChatWidget { } fn queue_user_message(&mut self, user_message: UserMessage) { - if self.bottom_pane.is_task_running() { + if self.bottom_pane.is_task_running() || self.is_review_mode { self.queued_user_messages.push_back(user_message); self.refresh_queued_user_messages(); } else { @@ -2047,6 +2151,7 @@ impl ChatWidget { if !text.is_empty() { self.add_to_history(history_cell::new_user_prompt(text)); } + self.needs_final_message_separator = false; } @@ -2181,7 +2286,7 @@ impl ChatWidget { } } EventMsg::EnteredReviewMode(review_request) => { - self.on_entered_review_mode(review_request) + self.on_entered_review_mode(review_request, from_replay) } EventMsg::ExitedReviewMode(review) => self.on_exited_review_mode(review), EventMsg::ContextCompacted(_) => self.on_agent_message("Context compacted".to_owned()), @@ -2195,11 +2300,15 @@ impl ChatWidget { } } - fn on_entered_review_mode(&mut self, review: ReviewRequest) { + fn on_entered_review_mode(&mut self, review: ReviewRequest, from_replay: bool) { // Enter review mode and emit a concise banner if self.pre_review_token_info.is_none() { self.pre_review_token_info = Some(self.token_info.clone()); } + // Avoid toggling running state for replayed history events on resume. + if !from_replay && !self.bottom_pane.is_task_running() { + self.bottom_pane.set_task_running(true); + } self.is_review_mode = true; let hint = review .user_facing_hint @@ -2259,6 +2368,12 @@ impl ChatWidget { self.frame_requester.schedule_frame(); } + fn bump_active_cell_revision(&mut self) { + // Wrapping avoids overflow; wraparound would require 2^64 bumps and at + // worst causes a one-time cache-key collision. + self.active_cell_revision = self.active_cell_revision.wrapping_add(1); + } + fn notify(&mut self, notification: Notification) { if !notification.allowed_for(&self.config.tui_notifications) { return; @@ -3566,6 +3681,9 @@ impl ChatWidget { } else { self.config.features.disable(feature); } + if feature == Feature::Steer { + self.bottom_pane.set_steer_enabled(enabled); + } } pub(crate) fn set_full_access_warning_acknowledged(&mut self, acknowledged: bool) { @@ -3673,9 +3791,12 @@ impl ChatWidget { self.bottom_pane.clear_esc_backtrack_hint(); } /// Forward an `Op` directly to codex. - pub(crate) fn submit_op(&self, op: Op) { + pub(crate) fn submit_op(&mut self, op: Op) { // Record outbound operation for session replay fidelity. crate::session_log::log_outbound_op(&op); + if matches!(&op, Op::Review { .. }) && !self.bottom_pane.is_task_running() { + self.bottom_pane.set_task_running(true); + } if let Err(e) = self.codex_op_tx.send(op) { tracing::error!("failed to submit op: {e}"); } @@ -3876,6 +3997,37 @@ impl ChatWidget { self.current_rollout_path.clone() } + /// Returns a cache key describing the current in-flight active cell for the transcript overlay. + /// + /// `Ctrl+T` renders committed transcript cells plus a render-only live tail derived from the + /// current active cell, and the overlay caches that tail; this key is what it uses to decide + /// whether it must recompute. When there is no active cell, this returns `None` so the overlay + /// can drop the tail entirely. + /// + /// If callers mutate the active cell's transcript output without bumping the revision (or + /// providing an appropriate animation tick), the overlay will keep showing a stale tail while + /// the main viewport updates. + pub(crate) fn active_cell_transcript_key(&self) -> Option { + let cell = self.active_cell.as_ref()?; + Some(ActiveCellTranscriptKey { + revision: self.active_cell_revision, + is_stream_continuation: cell.is_stream_continuation(), + animation_tick: cell.transcript_animation_tick(), + }) + } + + /// Returns the active cell's transcript lines for a given terminal width. + /// + /// This is a convenience for the transcript overlay live-tail path, and it intentionally + /// filters out empty results so the overlay can treat "nothing to render" as "no tail". Callers + /// should pass the same width the overlay uses; using a different width will cause wrapping + /// mismatches between the main viewport and the transcript overlay. + pub(crate) fn active_cell_transcript_lines(&self, width: u16) -> Option>> { + let cell = self.active_cell.as_ref()?; + let lines = cell.transcript_lines(width); + (!lines.is_empty()).then_some(lines) + } + /// Return a reference to the widget's current config (includes any /// runtime overrides applied via TUI, e.g., model or approval policy). pub(crate) fn config_ref(&self) -> &Config { diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap index c3bdf60bd2..b51d759fe7 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap @@ -2,6 +2,33 @@ source: tui/src/chatwidget/tests.rs expression: term.backend().vt100().screen().contents() --- + + + + + + + + + + + + + + + + + + + + + + + + + + + • I’m going to search the repo for where “Change Approved” is rendered to update that view. @@ -14,4 +41,4 @@ expression: term.backend().vt100().screen().contents() › Summarize recent commits - 100% context left + 100% context left · tab to queue message diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__review_queues_user_messages_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__review_queues_user_messages_snapshot.snap new file mode 100644 index 0000000000..2a7717df7c --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__review_queues_user_messages_snapshot.snap @@ -0,0 +1,23 @@ +--- +source: tui/src/chatwidget/tests.rs +assertion_line: 3840 +expression: term.backend().vt100().screen().contents() +--- + + + + + + + + + + +• Working (0s • esc to interrupt) + ↳ Queued while /review is running. + ⌥ + ↑ edit + + +› Ask Codex to do anything + + 100% context left · ? for shortcuts diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 94aff345af..4567c56a92 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -372,7 +372,7 @@ async fn make_chatwidget_manual( if let Some(model) = model_override { cfg.model = Some(model.to_string()); } - let bottom = BottomPane::new(BottomPaneParams { + let mut bottom = BottomPane::new(BottomPaneParams { app_event_tx: app_event_tx.clone(), frame_requester: FrameRequester::test_dummy(), has_input_focus: true, @@ -382,6 +382,7 @@ async fn make_chatwidget_manual( animations_enabled: cfg.animations, skills: None, }); + bottom.set_steer_enabled(true); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); let codex_home = cfg.codex_home.clone(); let widget = ChatWidget { @@ -389,6 +390,7 @@ async fn make_chatwidget_manual( codex_op_tx: op_tx, bottom_pane: bottom, active_cell: None, + active_cell_revision: 0, config: cfg, model: resolved_model.clone(), auth_manager: auth_manager.clone(), @@ -1057,7 +1059,7 @@ async fn enqueueing_history_prompt_multiple_times_is_stable() { assert_eq!(chat.bottom_pane.composer_text(), "repeat me"); // Queue the prompt while the task is running. - chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); } assert_eq!(chat.queued_user_messages.len(), 3); @@ -1079,7 +1081,7 @@ async fn streaming_final_answer_keeps_task_running_state() { chat.bottom_pane .set_composer_text("queued submission".to_string()); - chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); assert_eq!(chat.queued_user_messages.len(), 1); assert_eq!( @@ -1306,6 +1308,66 @@ async fn unified_exec_end_after_task_complete_is_suppressed() { ); } +#[tokio::test] +async fn unified_exec_wait_cell_revision_updates_on_late_command_display() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.active_cell = Some(Box::new(crate::history_cell::new_unified_exec_wait_live( + None, + chat.config.animations, + ))); + chat.unified_exec_processes.push(UnifiedExecProcessSummary { + key: "proc-1".to_string(), + command_display: "sleep 5".to_string(), + }); + + let before = chat.active_cell_revision; + chat.on_terminal_interaction(TerminalInteractionEvent { + call_id: "call-1".to_string(), + process_id: "proc-1".to_string(), + stdin: String::new(), + }); + + assert_eq!(chat.active_cell_revision, before.wrapping_add(1)); + let lines = chat + .active_cell_transcript_lines(80) + .expect("active cell lines"); + let blob = lines_to_single_string(&lines); + assert!( + blob.contains("sleep 5"), + "expected command display to render: {blob:?}" + ); +} + +#[tokio::test] +async fn unified_exec_wait_cell_revision_updates_on_replacement() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.active_cell = Some(Box::new(crate::history_cell::new_unified_exec_wait_live( + Some("old command".to_string()), + chat.config.animations, + ))); + chat.unified_exec_processes.push(UnifiedExecProcessSummary { + key: "proc-2".to_string(), + command_display: "new command".to_string(), + }); + + let before = chat.active_cell_revision; + chat.on_terminal_interaction(TerminalInteractionEvent { + call_id: "call-2".to_string(), + process_id: "proc-2".to_string(), + stdin: String::new(), + }); + + assert_eq!(chat.active_cell_revision, before.wrapping_add(1)); + let lines = chat + .active_cell_transcript_lines(80) + .expect("active cell lines"); + let blob = lines_to_single_string(&lines); + assert!( + blob.contains("new command"), + "expected replacement wait cell to render: {blob:?}" + ); +} + #[tokio::test] async fn unified_exec_waiting_multiple_empty_snapshots() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -3809,3 +3871,33 @@ async fn chatwidget_tall() { .unwrap(); assert_snapshot!(term.backend().vt100().screen().contents()); } + +#[tokio::test] +async fn review_queues_user_messages_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.handle_codex_event(Event { + id: "review-1".into(), + msg: EventMsg::EnteredReviewMode(ReviewRequest { + target: ReviewTarget::UncommittedChanges, + user_facing_hint: Some("current changes".to_string()), + }), + }); + let _ = drain_insert_history(&mut rx); + + chat.queue_user_message(UserMessage::from( + "Queued while /review is running.".to_string(), + )); + + let width: u16 = 80; + let height: u16 = 18; + let backend = VT100Backend::new(width, height); + let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); + let desired_height = chat.desired_height(width).min(height); + term.set_viewport_area(Rect::new(0, height - desired_height, width, desired_height)); + term.draw(|f| { + chat.render(f.area(), f.buffer_mut()); + }) + .unwrap(); + assert_snapshot!(term.backend().vt100().screen().contents()); +} diff --git a/codex-rs/tui/src/cli.rs b/codex-rs/tui/src/cli.rs index 43f2ae588e..9f97ab3c87 100644 --- a/codex-rs/tui/src/cli.rs +++ b/codex-rs/tui/src/cli.rs @@ -58,7 +58,7 @@ pub struct Cli { #[arg(long = "oss", default_value_t = false)] pub oss: bool, - /// Specify which local provider to use (lmstudio or ollama). + /// Specify which local provider to use (lmstudio, ollama, or ollama-chat). /// If not specified with --oss, will use config default or show selection. #[arg(long = "local-provider")] pub oss_provider: Option, diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 4e16306d13..34925ee503 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -1,3 +1,15 @@ +//! Transcript/history cells for the Codex TUI. +//! +//! A `HistoryCell` is the unit of display in the conversation UI, representing both committed +//! transcript entries and, transiently, an in-flight active cell that can mutate in place while +//! streaming. +//! +//! The transcript overlay (`Ctrl+T`) appends a cached live tail derived from the active cell, and +//! that cached tail is refreshed based on an active-cell cache key. Cells that change based on +//! elapsed time expose `transcript_animation_tick()`, and code that mutates the active cell in place +//! bumps the active-cell revision tracked by `ChatWidget`, so the cache key changes whenever the +//! rendered transcript output can change. + use crate::diff_render::create_diff_summary; use crate::diff_render::display_path_for; use crate::exec_cell::CommandOutput; @@ -100,6 +112,20 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any { fn is_stream_continuation(&self) -> bool { false } + + /// Returns a coarse "animation tick" when transcript output is time-dependent. + /// + /// The transcript overlay caches the rendered output of the in-flight active cell, so cells + /// that include time-based UI (spinner, shimmer, etc.) should return a tick that changes over + /// time to signal that the cached tail should be recomputed. Returning `None` means the + /// transcript lines are stable, while returning `Some(tick)` during an in-flight animation + /// allows the overlay to keep up with the main viewport. + /// + /// If a cell uses time-based visuals but always returns `None`, `Ctrl+T` can appear "frozen" on + /// the first rendered frame even though the main viewport is animating. + fn transcript_animation_tick(&self) -> Option { + None + } } impl Renderable for Box { @@ -448,6 +474,7 @@ pub(crate) fn new_unified_exec_interaction( pub(crate) struct UnifiedExecWaitCell { command_display: Option, animations_enabled: bool, + start_time: Instant, } impl UnifiedExecWaitCell { @@ -455,6 +482,7 @@ impl UnifiedExecWaitCell { Self { command_display: command_display.filter(|display| !display.is_empty()), animations_enabled, + start_time: Instant::now(), } } @@ -466,10 +494,19 @@ impl UnifiedExecWaitCell { } } - pub(crate) fn update_command_display(&mut self, command_display: Option) { - if self.command_display.is_none() { - self.command_display = command_display.filter(|display| !display.is_empty()); + /// Update the command display once. + /// + /// Unified exec can start without a stable command string, and later correlate a process id to + /// a user-facing `command_display`. This method records that first non-empty command display and + /// returns whether it changed the cell; callers use the `true` case to invalidate any cached + /// transcript rendering (for example, the transcript overlay live tail). + pub(crate) fn update_command_display(&mut self, command_display: Option) -> bool { + let command_display = command_display.filter(|display| !display.is_empty()); + if self.command_display.is_some() || command_display.is_none() { + return false; } + self.command_display = command_display; + true } pub(crate) fn command_display(&self) -> Option { @@ -507,6 +544,14 @@ impl HistoryCell for UnifiedExecWaitCell { fn desired_height(&self, width: u16) -> u16 { self.display_lines(width).len() as u16 } + + fn transcript_animation_tick(&self) -> Option { + if !self.animations_enabled { + return None; + } + // Match `App`'s frame scheduling cadence for transcript overlay live-tail animation. + Some((self.start_time.elapsed().as_millis() / 50) as u64) + } } pub(crate) fn new_unified_exec_wait_live( @@ -1252,6 +1297,13 @@ impl HistoryCell for McpToolCallCell { lines } + + fn transcript_animation_tick(&self) -> Option { + if !self.animations_enabled || self.result.is_some() { + return None; + } + Some((self.start_time.elapsed().as_millis() / 50) as u64) + } } pub(crate) fn new_active_mcp_tool_call( @@ -1871,7 +1923,8 @@ mod tests { enabled_tools: None, disabled_tools: None, }; - config.mcp_servers.insert("docs".to_string(), stdio_config); + let mut servers = config.mcp_servers.get().clone(); + servers.insert("docs".to_string(), stdio_config); let mut headers = HashMap::new(); headers.insert("Authorization".to_string(), "Bearer secret".to_string()); @@ -1890,7 +1943,11 @@ mod tests { enabled_tools: None, disabled_tools: None, }; - config.mcp_servers.insert("http".to_string(), http_config); + servers.insert("http".to_string(), http_config); + config + .mcp_servers + .set(servers) + .expect("test mcp servers should accept any configuration"); let mut tools: HashMap = HashMap::new(); tools.insert( diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index bf572bf3d6..5612f6615b 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -9,6 +9,7 @@ pub use app::AppExitInfo; use codex_app_server_protocol::AuthMode; use codex_common::oss::ensure_oss_provider_ready; use codex_common::oss::get_default_model_for_oss_provider; +use codex_common::oss::ollama_chat_deprecation_notice; use codex_core::AuthManager; use codex_core::CodexAuth; use codex_core::INTERACTIVE_SESSION_SOURCES; @@ -431,6 +432,13 @@ async fn run_ratatui_app( initial_config }; + let ollama_chat_support_notice = match ollama_chat_deprecation_notice(&config).await { + Ok(notice) => notice, + Err(err) => { + tracing::warn!(?err, "Failed to detect Ollama wire API"); + None + } + }; let mut missing_session_exit = |id_str: &str, action: &str| { error!("Error finding conversation path: {id_str}"); restore(); @@ -566,6 +574,7 @@ async fn run_ratatui_app( session_selection, feedback, should_show_trust_screen, // Proxy to: is it a first run in this directory? + ollama_chat_support_notice, ) .await; diff --git a/codex-rs/tui/src/model_migration.rs b/codex-rs/tui/src/model_migration.rs index cf2208109a..f8b240e16f 100644 --- a/codex-rs/tui/src/model_migration.rs +++ b/codex-rs/tui/src/model_migration.rs @@ -1,4 +1,5 @@ use crate::key_hint; +use crate::markdown_render::render_markdown_text_with_width; use crate::render::Insets; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; @@ -34,6 +35,7 @@ pub(crate) struct ModelMigrationCopy { pub heading: Vec>, pub content: Vec>, pub can_opt_out: bool, + pub markdown: Option, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -55,15 +57,30 @@ impl MigrationMenuOption { } } +#[allow(clippy::too_many_arguments)] pub(crate) fn migration_copy_for_models( current_model: &str, target_model: &str, model_link: Option, migration_copy: Option, + migration_markdown: Option, target_display_name: String, target_description: Option, can_opt_out: bool, ) -> ModelMigrationCopy { + if let Some(migration_markdown) = migration_markdown { + return ModelMigrationCopy { + heading: Vec::new(), + content: Vec::new(), + can_opt_out, + markdown: Some(fill_migration_markdown( + &migration_markdown, + current_model, + target_model, + )), + }; + } + let heading_text = Span::from(format!( "Codex just got an upgrade. Introducing {target_display_name}." )) @@ -113,6 +130,7 @@ pub(crate) fn migration_copy_for_models( heading: vec![heading_text], content, can_opt_out, + markdown: None, } } @@ -237,9 +255,13 @@ impl WidgetRef for &ModelMigrationScreen { let mut column = ColumnRenderable::new(); column.push(""); - column.push(self.heading_line()); - column.push(Line::from("")); - self.render_content(&mut column); + if let Some(markdown) = self.copy.markdown.as_ref() { + self.render_markdown_content(markdown, area.width, &mut column); + } else { + column.push(self.heading_line()); + column.push(Line::from("")); + self.render_content(&mut column); + } if self.copy.can_opt_out { self.render_menu(&mut column); } @@ -290,6 +312,21 @@ impl ModelMigrationScreen { } } + fn render_markdown_content( + &self, + markdown: &str, + area_width: u16, + column: &mut ColumnRenderable, + ) { + let horizontal_inset = 2; + let content_width = area_width.saturating_sub(horizontal_inset); + let wrap_width = (content_width > 0).then_some(content_width as usize); + let rendered = render_markdown_text_with_width(markdown, wrap_width); + for line in rendered.lines { + column.push(line.inset(Insets::tlbr(0, horizontal_inset, 0, 0))); + } + } + fn render_menu(&self, column: &mut ColumnRenderable) { column.push(Line::from("")); column.push( @@ -348,6 +385,12 @@ fn is_ctrl_exit_combo(key_event: KeyEvent) -> bool { && matches!(key_event.code, KeyCode::Char('c') | KeyCode::Char('d')) } +fn fill_migration_markdown(template: &str, current_model: &str, target_model: &str) -> String { + template + .replace("{model_from}", current_model) + .replace("{model_to}", target_model) +} + #[cfg(test)] mod tests { use super::ModelMigrationScreen; @@ -378,6 +421,7 @@ mod tests { "Upgrade to gpt-5.2-codex for the latest and greatest agentic coding model." .to_string(), ), + None, "gpt-5.1-codex-max".to_string(), Some("Codex-optimized flagship for deep and fast reasoning.".to_string()), true, @@ -406,6 +450,7 @@ mod tests { "gpt-5.1", Some("https://www.codex.com/models/gpt-5.1".to_string()), None, + None, "gpt-5.1".to_string(), Some("Broad world knowledge with strong general reasoning.".to_string()), false, @@ -432,6 +477,7 @@ mod tests { "gpt-5.1-codex-max", Some("https://www.codex.com/models/gpt-5.1-codex-max".to_string()), None, + None, "gpt-5.1-codex-max".to_string(), Some("Codex-optimized flagship for deep and fast reasoning.".to_string()), false, @@ -458,6 +504,7 @@ mod tests { "gpt-5.1-codex-mini", Some("https://www.codex.com/models/gpt-5.1-codex-mini".to_string()), None, + None, "gpt-5.1-codex-mini".to_string(), Some("Optimized for codex. Cheaper, faster, but less capable.".to_string()), false, @@ -480,6 +527,7 @@ mod tests { "gpt-new", Some("https://www.codex.com/models/gpt-new".to_string()), None, + None, "gpt-new".to_string(), Some("Latest recommended model for better performance.".to_string()), true, @@ -508,6 +556,7 @@ mod tests { "gpt-new", Some("https://www.codex.com/models/gpt-new".to_string()), None, + None, "gpt-new".to_string(), Some("Latest recommended model for better performance.".to_string()), true, diff --git a/codex-rs/tui/src/oss_selection.rs b/codex-rs/tui/src/oss_selection.rs index eb1ca18231..7267919109 100644 --- a/codex-rs/tui/src/oss_selection.rs +++ b/codex-rs/tui/src/oss_selection.rs @@ -4,6 +4,7 @@ use std::sync::LazyLock; use codex_core::DEFAULT_LMSTUDIO_PORT; use codex_core::DEFAULT_OLLAMA_PORT; use codex_core::LMSTUDIO_OSS_PROVIDER_ID; +use codex_core::OLLAMA_CHAT_PROVIDER_ID; use codex_core::OLLAMA_OSS_PROVIDER_ID; use codex_core::config::set_default_oss_provider; use crossterm::event::Event; @@ -70,10 +71,16 @@ static OSS_SELECT_OPTIONS: LazyLock> = LazyLock::new(|| { }, SelectOption { label: Line::from(vec!["O".underlined(), "llama".into()]), - description: "Local Ollama server (default port 11434)", + description: "Local Ollama server (Responses API, default port 11434)", key: KeyCode::Char('o'), provider_id: OLLAMA_OSS_PROVIDER_ID, }, + SelectOption { + label: Line::from(vec!["Ollama (".into(), "c".underlined(), "hat)".into()]), + description: "Local Ollama server (chat wire API, default port 11434)", + key: KeyCode::Char('c'), + provider_id: OLLAMA_CHAT_PROVIDER_ID, + }, ] }); @@ -99,7 +106,11 @@ impl OssSelectionWidget<'_> { status: lmstudio_status, }, ProviderOption { - name: "Ollama".to_string(), + name: "Ollama (Responses)".to_string(), + status: ollama_status.clone(), + }, + ProviderOption { + name: "Ollama (Chat)".to_string(), status: ollama_status, }, ]; diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index 46aaba8644..ae71a58c6d 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -1,7 +1,25 @@ +//! Overlay UIs rendered in an alternate screen. +//! +//! This module implements the pager-style overlays used by the TUI, including the transcript +//! overlay (`Ctrl+T`) that renders a full history view separate from the main viewport. +//! +//! The transcript overlay renders committed transcript cells plus an optional render-only live tail +//! derived from the current in-flight active cell. Because rebuilding wrapped `Line`s on every draw +//! can be expensive, that live tail is cached and only recomputed when its cache key changes, which +//! is derived from the terminal width (wrapping), an active-cell revision (in-place mutations), the +//! stream-continuation flag (spacing), and an animation tick (time-based spinner/shimmer output). +//! +//! The transcript overlay live tail is kept in sync by `App` during draws: `App` supplies an +//! `ActiveCellTranscriptKey` and a function to compute the active cell transcript lines, and +//! `TranscriptOverlay::sync_live_tail` uses the key to decide when the cached tail must be +//! recomputed. `ChatWidget` is responsible for producing a key that changes when the active cell +//! mutates in place or when its transcript output is time-dependent. + use std::io::Result; use std::sync::Arc; use std::time::Duration; +use crate::chatwidget::ActiveCellTranscriptKey; use crate::history_cell::HistoryCell; use crate::history_cell::UserHistoryCell; use crate::key_hint; @@ -401,13 +419,39 @@ impl Renderable for CellRenderable { } pub(crate) struct TranscriptOverlay { + /// Pager UI state and the renderables currently displayed. + /// + /// The invariant is that `view.renderables` is `render_cells(cells)` plus an optional trailing + /// live-tail renderable appended after the committed cells. view: PagerView, + /// Committed transcript cells (does not include the live tail). cells: Vec>, highlight_cell: Option, + /// Cache key for the render-only live tail appended after committed cells. + live_tail_key: Option, is_done: bool, } +/// Cache key for the active-cell "live tail" appended to the transcript overlay. +/// +/// Changing any field implies a different rendered tail. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct LiveTailKey { + /// Current terminal width, which affects wrapping. + width: u16, + /// Revision that changes on in-place active cell transcript updates. + revision: u64, + /// Whether the tail should be treated as a continuation for spacing. + is_stream_continuation: bool, + /// Optional animation tick to refresh spinners/progress indicators. + animation_tick: Option, +} + impl TranscriptOverlay { + /// Creates a transcript overlay for a fixed set of committed cells. + /// + /// 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 { Self { view: PagerView::new( @@ -417,6 +461,7 @@ impl TranscriptOverlay { ), cells: transcript_cells, highlight_cell: None, + live_tail_key: None, is_done: false, } } @@ -457,10 +502,85 @@ impl TranscriptOverlay { .collect() } + /// Insert a committed history cell while keeping any cached live tail. + /// + /// The live tail is temporarily removed, the committed cells are rebuilt, + /// then the tail is reattached. If the tail previously had no leading + /// spacing because it was the only renderable, we add the missing inset + /// when the first committed cell arrives. + /// + /// This expects `cell` to be a committed transcript cell (not the in-flight active cell). If + /// the overlay was scrolled to bottom before insertion, it remains pinned to bottom after the + /// insertion to preserve the "follow along" behavior. pub(crate) fn insert_cell(&mut self, cell: Arc) { let follow_bottom = self.view.is_scrolled_to_bottom(); + let had_prior_cells = !self.cells.is_empty(); + let tail_renderable = self.take_live_tail_renderable(); self.cells.push(cell); self.view.renderables = Self::render_cells(&self.cells, self.highlight_cell); + if let Some(tail) = tail_renderable { + let tail = if !had_prior_cells + && self + .live_tail_key + .is_some_and(|key| !key.is_stream_continuation) + { + // The tail was rendered as the only entry, so it lacks a top + // inset; add one now that it follows a committed cell. + Box::new(InsetRenderable::new(tail, Insets::tlbr(1, 0, 0, 0))) + as Box + } else { + tail + }; + self.view.renderables.push(tail); + } + if follow_bottom { + self.view.scroll_offset = usize::MAX; + } + } + + /// Sync the active-cell live tail with the current width and cell state. + /// + /// Recomputes the tail only when the cache key changes, preserving scroll + /// position and dropping the tail if there is nothing to render. + /// + /// The overlay owns committed transcript cells while the live tail is derived from the current + /// active cell, which can mutate in place while streaming. `App` calls this during + /// `TuiEvent::Draw` for `Overlay::Transcript`, passing a key that changes when the active cell + /// mutates or animates so the cached tail stays fresh. + /// + /// Passing a key that does not change on in-place active-cell mutations will freeze the tail in + /// `Ctrl+T` while the main viewport continues to update. + pub(crate) fn sync_live_tail( + &mut self, + width: u16, + active_key: Option, + compute_lines: impl FnOnce(u16) -> Option>>, + ) { + let next_key = active_key.map(|key| LiveTailKey { + width, + revision: key.revision, + is_stream_continuation: key.is_stream_continuation, + animation_tick: key.animation_tick, + }); + + if self.live_tail_key == next_key { + return; + } + let follow_bottom = self.view.is_scrolled_to_bottom(); + + self.take_live_tail_renderable(); + self.live_tail_key = next_key; + + if let Some(key) = next_key { + let lines = compute_lines(width).unwrap_or_default(); + if !lines.is_empty() { + self.view.renderables.push(Self::live_tail_renderable( + lines, + !self.cells.is_empty(), + key.is_stream_continuation, + )); + } + } if follow_bottom { self.view.scroll_offset = usize::MAX; } @@ -468,12 +588,50 @@ impl TranscriptOverlay { pub(crate) fn set_highlight_cell(&mut self, cell: Option) { self.highlight_cell = cell; - self.view.renderables = Self::render_cells(&self.cells, self.highlight_cell); + self.rebuild_renderables(); if let Some(idx) = self.highlight_cell { self.view.scroll_chunk_into_view(idx); } } + /// Returns whether the underlying pager view is currently pinned to the bottom. + /// + /// The `App` draw loop uses this to decide whether to schedule animation frames for the live + /// tail; if the user has scrolled up, we avoid driving animation work that they cannot see. + pub(crate) fn is_scrolled_to_bottom(&self) -> bool { + self.view.is_scrolled_to_bottom() + } + + fn rebuild_renderables(&mut self) { + let tail_renderable = self.take_live_tail_renderable(); + self.view.renderables = Self::render_cells(&self.cells, self.highlight_cell); + if let Some(tail) = tail_renderable { + self.view.renderables.push(tail); + } + } + + /// Removes and returns the cached live-tail renderable, if present. + /// + /// The live tail is represented as a single optional renderable appended after the committed + /// cell renderables, so this relies on the live tail always being the final entry in + /// `view.renderables` when present. + fn take_live_tail_renderable(&mut self) -> Option> { + (self.view.renderables.len() > self.cells.len()).then(|| self.view.renderables.pop())? + } + + fn live_tail_renderable( + lines: Vec>, + has_prior_cells: bool, + is_stream_continuation: bool, + ) -> Box { + let paragraph = Paragraph::new(Text::from(lines)); + let mut renderable: Box = Box::new(CachedRenderable::new(paragraph)); + if has_prior_cells && !is_stream_continuation { + renderable = Box::new(InsetRenderable::new(renderable, Insets::tlbr(1, 0, 0, 0))); + } + renderable + } + 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); @@ -612,6 +770,7 @@ mod tests { use codex_core::protocol::ExecCommandSource; use codex_core::protocol::ReviewDecision; use insta::assert_snapshot; + use pretty_assertions::assert_eq; use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; @@ -696,6 +855,52 @@ mod tests { assert_snapshot!(term.backend()); } + #[test] + fn transcript_overlay_renders_live_tail() { + let mut overlay = TranscriptOverlay::new(vec![Arc::new(TestCell { + lines: vec![Line::from("alpha")], + })]); + overlay.sync_live_tail( + 40, + Some(ActiveCellTranscriptKey { + revision: 1, + is_stream_continuation: false, + animation_tick: None, + }), + |_| Some(vec![Line::from("tail")]), + ); + + let mut term = Terminal::new(TestBackend::new(40, 10)).expect("term"); + term.draw(|f| overlay.render(f.area(), f.buffer_mut())) + .expect("draw"); + assert_snapshot!(term.backend()); + } + + #[test] + fn transcript_overlay_sync_live_tail_is_noop_for_identical_key() { + let mut overlay = TranscriptOverlay::new(vec![Arc::new(TestCell { + lines: vec![Line::from("alpha")], + })]); + + let calls = std::cell::Cell::new(0usize); + let key = ActiveCellTranscriptKey { + revision: 1, + is_stream_continuation: false, + animation_tick: None, + }; + + overlay.sync_live_tail(40, Some(key), |_| { + calls.set(calls.get() + 1); + Some(vec![Line::from("tail")]) + }); + overlay.sync_live_tail(40, Some(key), |_| { + calls.set(calls.get() + 1); + Some(vec![Line::from("tail2")]) + }); + + assert_eq!(calls.get(), 1); + } + fn buffer_to_text(buf: &Buffer, area: Rect) -> String { let mut out = String::new(); for y in area.y..area.bottom() { diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__model_migration_prompt_shows_for_hidden_model.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__model_migration_prompt_shows_for_hidden_model.snap index 9016aebea8..4c9f59f4c0 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__tests__model_migration_prompt_shows_for_hidden_model.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__model_migration_prompt_shows_for_hidden_model.snap @@ -1,9 +1,8 @@ --- source: tui/src/app.rs -assertion_line: 1579 expression: model_migration_copy_to_plain_text(©) --- -Codex just got an upgrade. Introducing gpt-5.2-codex. +**Codex just got an upgrade. Introducing gpt-5.2-codex.** Codex is now powered by gpt-5.2-codex, our latest frontier agentic coding model. It is smarter and faster than its predecessors and capable of long-running project-scale work. Learn more about gpt-5.2-codex at https://openai.com/index/introducing-gpt-5-2-codex diff --git a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_renders_live_tail.snap b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_renders_live_tail.snap new file mode 100644 index 0000000000..05ea902460 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_renders_live_tail.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/pager_overlay.rs +expression: term.backend() +--- +"/ T R A N S C R I P T / / / / / / / / / " +"alpha " +" " +"tail " +"~ " +"~ " +"───────────────────────────────── 100% ─" +" ↑/↓ to scroll pgup/pgdn to page hom" +" q to quit esc to edit prev " +" " diff --git a/codex-rs/tui/tooltips.txt b/codex-rs/tui/tooltips.txt index 33e891611b..0fdb0ca73b 100644 --- a/codex-rs/tui/tooltips.txt +++ b/codex-rs/tui/tooltips.txt @@ -6,5 +6,6 @@ You can run any shell command from Codex using `!` (e.g. `!ls`) Type / to open the command popup; Tab autocompletes slash commands and saved prompts. You can define your own `/` commands with custom prompts. More info: https://developers.openai.com/codex/guides/slash-commands#create-your-own-slash-commands-with-custom-prompts When the composer is empty, press Esc to step back and edit your last message; Enter confirms. +Press Tab to queue a message instead of sending it immediately; Enter always sends immediately. Paste an image with Ctrl+V to attach it to your next message. You can resume a previous conversation by running `codex resume` diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs index 305ae114d6..4e3ac3795c 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -52,6 +52,7 @@ use codex_core::features::Feature; use codex_core::models_manager::manager::ModelsManager; use codex_core::models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; use codex_core::models_manager::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; +use codex_core::protocol::DeprecationNoticeEvent; use codex_core::protocol::EventMsg; use codex_core::protocol::FinalOutput; use codex_core::protocol::ListSkillsResponseEvent; @@ -162,6 +163,15 @@ fn emit_skill_load_warnings(app_event_tx: &AppEventSender, errors: &[SkillErrorI } } +fn emit_deprecation_notice(app_event_tx: &AppEventSender, notice: Option) { + let Some(DeprecationNoticeEvent { summary, details }) = notice else { + return; + }; + app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + crate::history_cell::new_deprecation_notice(summary, details), + ))); +} + #[derive(Debug, Clone, PartialEq, Eq)] struct SessionSummary { usage_line: String, @@ -231,6 +241,7 @@ async fn handle_model_migration_prompt_if_needed( id: target_model, reasoning_effort_mapping, migration_config_key, + migration_markdown, .. }) = upgrade { @@ -273,6 +284,7 @@ async fn handle_model_migration_prompt_if_needed( &target_model, heading_label, target_description, + migration_markdown.clone(), can_opt_out, ); match run_model_migration_prompt(tui, prompt_copy).await { @@ -406,10 +418,12 @@ impl App { session_selection: SessionSelection, feedback: codex_feedback::CodexFeedback, is_first_run: bool, + ollama_chat_support_notice: Option, ) -> Result { use tokio_stream::StreamExt; let (app_event_tx, mut app_event_rx) = unbounded_channel(); let app_event_tx = AppEventSender::new(app_event_tx); + emit_deprecation_notice(&app_event_tx, ollama_chat_support_notice); let thread_manager = Arc::new(ThreadManager::new( config.codex_home.clone(), @@ -1592,9 +1606,6 @@ impl App { } self.chat_widget.handle_codex_event(event); } - AppEvent::ConversationHistory(ev) => { - self.on_conversation_history_for_backtrack(tui, ev).await?; - } AppEvent::ExitRequest => { return Ok(false); } @@ -2192,8 +2203,9 @@ impl App { && self.backtrack.nth_user_message != usize::MAX && self.chat_widget.composer_is_empty() => { - // Delegate to helper for clarity; preserves behavior. - self.confirm_backtrack_from_main(); + if let Some(selection) = self.confirm_backtrack_from_main() { + self.apply_backtrack_selection(tui, selection); + } } KeyEvent { kind: KeyEventKind::Press | KeyEventKind::Repeat, @@ -2384,6 +2396,9 @@ mod tests { fn model_migration_copy_to_plain_text( copy: &crate::model_migration::ModelMigrationCopy, ) -> String { + if let Some(markdown) = copy.markdown.as_ref() { + return markdown.clone(); + } let mut s = String::new(); for span in ©.heading { s.push_str(&span.content); @@ -2478,6 +2493,7 @@ mod tests { &upgrade.id, target.display_name, target_description, + upgrade.migration_markdown.clone(), can_opt_out, ); @@ -2558,7 +2574,7 @@ mod tests { #[tokio::test] async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { - let mut app = make_test_app().await; + let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await; let user_cell = |text: &str| -> Arc { Arc::new(UserHistoryCell { @@ -2594,9 +2610,8 @@ mod tests { )) as Arc }; - // Simulate the transcript after trimming for a fork, replaying history, and - // appending the edited turn. The session header separates the retained history - // from the forked conversation's replayed turns. + // Simulate a transcript with duplicated history (e.g., from prior backtracks) + // and an edited turn appended after a session header boundary. app.transcript_cells = vec![ make_header(true), user_cell("first question"), @@ -2612,15 +2627,44 @@ mod tests { assert_eq!(user_count(&app.transcript_cells), 2); - app.backtrack.base_id = Some(ThreadId::new()); + let base_id = ThreadId::new(); + app.chat_widget.handle_codex_event(Event { + id: String::new(), + msg: EventMsg::SessionConfigured(SessionConfiguredEvent { + session_id: base_id, + model: "gpt-test".to_string(), + model_provider_id: "test-provider".to_string(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::ReadOnly, + cwd: PathBuf::from("/home/user/project"), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + rollout_path: PathBuf::new(), + }), + }); + + app.backtrack.base_id = Some(base_id); app.backtrack.primed = true; app.backtrack.nth_user_message = user_count(&app.transcript_cells).saturating_sub(1); - app.confirm_backtrack_from_main(); + let selection = app + .confirm_backtrack_from_main() + .expect("backtrack selection"); + assert_eq!(selection.nth_user_message, 1); + assert_eq!(selection.prefill, "follow-up (edited)"); - let (_, nth, prefill) = app.backtrack.pending.clone().expect("pending backtrack"); - assert_eq!(nth, 1); - assert_eq!(prefill, "follow-up (edited)"); + app.apply_backtrack_rollback(selection); + + let mut rollback_turns = None; + while let Ok(op) = op_rx.try_recv() { + if let Op::ThreadRollback { num_turns } = op { + rollback_turns = Some(num_turns); + } + } + + assert_eq!(rollback_turns, Some(1)); } #[tokio::test] diff --git a/codex-rs/tui2/src/app_backtrack.rs b/codex-rs/tui2/src/app_backtrack.rs index c5c2f0e95f..1f5ec3e45e 100644 --- a/codex-rs/tui2/src/app_backtrack.rs +++ b/codex-rs/tui2/src/app_backtrack.rs @@ -1,5 +1,17 @@ +//! Backtracking and transcript overlay event routing. +//! +//! This file owns backtrack mode (Esc/Enter navigation in the transcript overlay) and also +//! mediates a key rendering boundary for the transcript overlay. +//! +//! The transcript overlay (`Ctrl+T`) renders committed transcript cells plus a render-only live +//! tail derived from the current in-flight `ChatWidget.active_cell`. +//! +//! That live tail is kept in sync during `TuiEvent::Draw` handling for `Overlay::Transcript` by +//! asking `ChatWidget` for an active-cell cache key and transcript lines and by passing them into +//! `TranscriptOverlay::sync_live_tail`. This preserves the invariant that the overlay reflects +//! both committed history and in-flight activity without changing flush or coalescing behavior. + use std::any::TypeId; -use std::path::PathBuf; use std::sync::Arc; use crate::app::App; @@ -8,7 +20,7 @@ use crate::history_cell::UserHistoryCell; use crate::pager_overlay::Overlay; use crate::tui; use crate::tui::TuiEvent; -use codex_core::protocol::ConversationPathResponseEvent; +use codex_core::protocol::Op; use codex_protocol::ThreadId; use color_eyre::eyre::Result; use crossterm::event::KeyCode; @@ -20,14 +32,18 @@ use crossterm::event::KeyEventKind; pub(crate) struct BacktrackState { /// True when Esc has primed backtrack mode in the main view. pub(crate) primed: bool, - /// Session id of the base conversation to fork from. + /// Session id of the base thread to rollback. pub(crate) base_id: Option, /// Index in the transcript of the last user message. pub(crate) nth_user_message: usize, /// True when the transcript overlay is showing a backtrack preview. pub(crate) overlay_preview_active: bool, - /// Pending fork request: (base_id, nth_user_message, prefill). - pub(crate) pending: Option<(ThreadId, usize, String)>, +} + +#[derive(Debug, Clone)] +pub(crate) struct BacktrackSelection { + pub(crate) nth_user_message: usize, + pub(crate) prefill: String, } impl App { @@ -95,23 +111,22 @@ impl App { } } - /// Stage a backtrack and request conversation history from the agent. - pub(crate) fn request_backtrack( - &mut self, - prefill: String, - base_id: ThreadId, - nth_user_message: usize, - ) { - self.backtrack.pending = Some((base_id, nth_user_message, prefill)); - if let Some(path) = self.chat_widget.rollout_path() { - let ev = ConversationPathResponseEvent { - conversation_id: base_id, - path, - }; - self.app_event_tx - .send(crate::app_event::AppEvent::ConversationHistory(ev)); - } else { - tracing::error!("rollout path unavailable; cannot backtrack"); + pub(crate) fn apply_backtrack_rollback(&mut self, selection: BacktrackSelection) { + let user_total = user_count(&self.transcript_cells); + if user_total == 0 { + return; + } + + let num_turns = user_total.saturating_sub(selection.nth_user_message); + let num_turns = u32::try_from(num_turns).unwrap_or(u32::MAX); + if num_turns == 0 { + return; + } + + self.chat_widget.submit_op(Op::ThreadRollback { num_turns }); + self.trim_transcript_for_backtrack(selection.nth_user_message); + if !selection.prefill.is_empty() { + self.chat_widget.set_composer_text(selection.prefill); } } @@ -203,7 +218,7 @@ impl App { self.backtrack.overlay_preview_active = true; let count = user_count(&self.transcript_cells); if let Some(last) = count.checked_sub(1) { - self.apply_backtrack_selection(last); + self.apply_backtrack_selection_internal(last); } tui.frame_requester().schedule_frame(); } @@ -227,12 +242,12 @@ impl App { .min(last_index) }; - self.apply_backtrack_selection(next_selection); + self.apply_backtrack_selection_internal(next_selection); tui.frame_requester().schedule_frame(); } /// Apply a computed backtrack selection to the overlay and internal counter. - fn apply_backtrack_selection(&mut self, nth_user_message: usize) { + fn apply_backtrack_selection_internal(&mut self, nth_user_message: usize) { if let Some(cell_idx) = nth_user_position(&self.transcript_cells, nth_user_message) { self.backtrack.nth_user_message = nth_user_message; if let Some(Overlay::Transcript(t)) = &mut self.overlay { @@ -248,6 +263,37 @@ impl App { /// Forward any event to the overlay and close it if done. fn overlay_forward_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> { + // Transcript overlay draws are special: include a live, in-flight tail so the + // overlay matches the main viewport while the active cell is still streaming. + // This path also drives tail animations and closes the overlay immediately + // once it reports completion. + if let TuiEvent::Draw = &event + && let Some(Overlay::Transcript(t)) = &mut self.overlay + { + let active_key = self.chat_widget.active_cell_transcript_key(); + let chat_widget = &self.chat_widget; + tui.draw(u16::MAX, |frame| { + let width = frame.area().width.max(1); + t.sync_live_tail(width, active_key, |w| { + chat_widget.active_cell_transcript_lines(w) + }); + t.render(frame.area(), frame.buffer); + })?; + let close_overlay = t.is_done(); + if !close_overlay + && active_key.is_some_and(|key| key.animation_tick.is_some()) + && t.is_scrolled_to_bottom() + { + tui.frame_requester() + .schedule_frame_in(std::time::Duration::from_millis(50)); + } + if close_overlay { + self.close_transcript_overlay(tui); + tui.frame_requester().schedule_frame(); + } + return Ok(()); + } + if let Some(overlay) = &mut self.overlay { overlay.handle_event(tui, event)?; if overlay.is_done() { @@ -261,16 +307,13 @@ impl App { /// Handle Enter in overlay backtrack preview: confirm selection and reset state. fn overlay_confirm_backtrack(&mut self, tui: &mut tui::Tui) { let nth_user_message = self.backtrack.nth_user_message; - if let Some(base_id) = self.backtrack.base_id { - let prefill = nth_user_position(&self.transcript_cells, nth_user_message) - .and_then(|idx| self.transcript_cells.get(idx)) - .and_then(|cell| cell.as_any().downcast_ref::()) - .map(|c| c.message.clone()) - .unwrap_or_default(); - self.close_transcript_overlay(tui); - self.request_backtrack(prefill, base_id, nth_user_message); + let selection = self.backtrack_selection(nth_user_message); + self.close_transcript_overlay(tui); + if let Some(selection) = selection { + self.apply_backtrack_rollback(selection); + self.render_transcript_once(tui); + tui.frame_requester().schedule_frame(); } - self.reset_backtrack_state(); } /// Handle Esc in overlay backtrack preview: step selection if armed, else forward. @@ -284,18 +327,11 @@ impl App { } /// Confirm a primed backtrack from the main view (no overlay visible). - /// Computes the prefill from the selected user message and requests history. - pub(crate) fn confirm_backtrack_from_main(&mut self) { - if let Some(base_id) = self.backtrack.base_id { - let prefill = - nth_user_position(&self.transcript_cells, self.backtrack.nth_user_message) - .and_then(|idx| self.transcript_cells.get(idx)) - .and_then(|cell| cell.as_any().downcast_ref::()) - .map(|c| c.message.clone()) - .unwrap_or_default(); - self.request_backtrack(prefill, base_id, self.backtrack.nth_user_message); - } + /// Computes the prefill from the selected user message for rollback. + pub(crate) fn confirm_backtrack_from_main(&mut self) -> Option { + let selection = self.backtrack_selection(self.backtrack.nth_user_message); self.reset_backtrack_state(); + selection } /// Clear all backtrack-related state and composer hints. @@ -307,88 +343,32 @@ impl App { self.chat_widget.clear_esc_backtrack_hint(); } - /// Handle a ConversationHistory response while a backtrack is pending. - /// If it matches the primed base session, fork and switch to the new thread. - pub(crate) async fn on_conversation_history_for_backtrack( + pub(crate) fn apply_backtrack_selection( &mut self, tui: &mut tui::Tui, - ev: ConversationPathResponseEvent, - ) -> Result<()> { - if let Some((base_id, _, _)) = self.backtrack.pending.as_ref() - && ev.conversation_id == *base_id - && let Some((_, nth_user_message, prefill)) = self.backtrack.pending.take() - { - self.fork_and_switch_to_new_conversation(tui, ev, nth_user_message, prefill) - .await; - } - Ok(()) - } - - /// Fork the thread using provided history and switch UI/state accordingly. - async fn fork_and_switch_to_new_conversation( - &mut self, - tui: &mut tui::Tui, - ev: ConversationPathResponseEvent, - nth_user_message: usize, - prefill: String, + selection: BacktrackSelection, ) { - let cfg = self.chat_widget.config_ref().clone(); - // Perform the fork via a thin wrapper for clarity/testability. - let result = self - .perform_fork(ev.path.clone(), nth_user_message, cfg.clone()) - .await; - match result { - Ok(new_conv) => { - self.install_forked_conversation(tui, cfg, new_conv, nth_user_message, &prefill) - } - Err(e) => tracing::error!("error forking conversation: {e:#}"), - } - } - - /// Thin wrapper around ThreadManager::fork_thread. - async fn perform_fork( - &self, - path: PathBuf, - nth_user_message: usize, - cfg: codex_core::config::Config, - ) -> codex_core::error::Result { - self.server.fork_thread(nth_user_message, cfg, path).await - } - - /// Install a forked thread into the ChatWidget and update UI to reflect selection. - fn install_forked_conversation( - &mut self, - tui: &mut tui::Tui, - cfg: codex_core::config::Config, - new_conv: codex_core::NewThread, - nth_user_message: usize, - prefill: &str, - ) { - let thread = new_conv.thread; - let session_configured = new_conv.session_configured; - let init = crate::chatwidget::ChatWidgetInit { - config: cfg, - model: self.current_model.clone(), - frame_requester: tui.frame_requester(), - app_event_tx: self.app_event_tx.clone(), - initial_prompt: None, - initial_images: Vec::new(), - enhanced_keys_supported: self.enhanced_keys_supported, - auth_manager: self.auth_manager.clone(), - models_manager: self.server.get_models_manager(), - feedback: self.feedback.clone(), - is_first_run: false, - }; - self.chat_widget = - crate::chatwidget::ChatWidget::new_from_existing(init, thread, session_configured); - // Trim transcript up to the selected user message and re-render it. - self.trim_transcript_for_backtrack(nth_user_message); + self.apply_backtrack_rollback(selection); self.render_transcript_once(tui); - if !prefill.is_empty() { - self.chat_widget.set_composer_text(prefill.to_string()); - } tui.frame_requester().schedule_frame(); } + fn backtrack_selection(&self, nth_user_message: usize) -> Option { + let base_id = self.backtrack.base_id?; + if self.chat_widget.conversation_id() != Some(base_id) { + return None; + } + + let prefill = nth_user_position(&self.transcript_cells, nth_user_message) + .and_then(|idx| self.transcript_cells.get(idx)) + .and_then(|cell| cell.as_any().downcast_ref::()) + .map(|c| c.message.clone()) + .unwrap_or_default(); + + Some(BacktrackSelection { + nth_user_message, + prefill, + }) + } /// Trim transcript_cells to preserve only content up to the selected user message. fn trim_transcript_for_backtrack(&mut self, nth_user_message: usize) { diff --git a/codex-rs/tui2/src/app_event.rs b/codex-rs/tui2/src/app_event.rs index a7e566edfa..3396d5ca06 100644 --- a/codex-rs/tui2/src/app_event.rs +++ b/codex-rs/tui2/src/app_event.rs @@ -1,7 +1,6 @@ use std::path::PathBuf; use codex_common::approval_presets::ApprovalPreset; -use codex_core::protocol::ConversationPathResponseEvent; use codex_core::protocol::Event; use codex_core::protocol::RateLimitSnapshot; use codex_file_search::FileMatch; @@ -180,9 +179,6 @@ pub(crate) enum AppEvent { /// Re-open the approval presets popup. OpenApprovalsPopup, - /// Forwarded conversation history snapshot from the current conversation. - ConversationHistory(ConversationPathResponseEvent), - /// Open the branch picker option from the review popup. OpenReviewBranchPicker(PathBuf), diff --git a/codex-rs/tui2/src/bottom_pane/AGENTS.md b/codex-rs/tui2/src/bottom_pane/AGENTS.md new file mode 100644 index 0000000000..44fac8e50a --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/AGENTS.md @@ -0,0 +1,15 @@ +# TUI2 bottom pane (state machines) + +When changing the paste-burst or chat-composer state machines in this folder, keep the docs in sync: + +- Update the relevant module docs (`chat_composer.rs` and/or `paste_burst.rs`) so they remain a + readable, top-down explanation of the current behavior. +- Update the narrative doc `docs/tui-chat-composer.md` whenever behavior/assumptions change (Enter + handling, retro-capture, flush/clear rules, `disable_paste_burst`, non-ASCII/IME handling). +- Keep `codex-rs/tui` and `codex-rs/tui2` implementations/docstrings aligned unless the divergence + is intentional and documented. + +Practical check: + +- After edits, sanity-check that docs mention only APIs/behavior that exist in code (especially the + Enter/newline paths and `disable_paste_burst` semantics). diff --git a/codex-rs/tui2/src/bottom_pane/chat_composer.rs b/codex-rs/tui2/src/bottom_pane/chat_composer.rs index 118dc8cd3d..cf823b132b 100644 --- a/codex-rs/tui2/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui2/src/bottom_pane/chat_composer.rs @@ -1,3 +1,61 @@ +//! The chat composer is the bottom-pane text input state machine. +//! +//! It is responsible for: +//! +//! - Editing the input buffer (a `TextArea`), including placeholder "elements" for attachments. +//! - Routing keys to the active popup (slash commands, file search, skill mentions). +//! - Handling submit vs newline on Enter. +//! - Turning raw key streams into explicit paste operations on platforms where terminals +//! don't provide reliable bracketed paste (notably Windows). +//! +//! # Key Event Routing +//! +//! Most key handling goes through [`ChatComposer::handle_key_event`], which dispatches to a +//! popup-specific handler if a popup is visible and otherwise to +//! [`ChatComposer::handle_key_event_without_popup`]. After every handled key, we call +//! [`ChatComposer::sync_popups`] so UI state follows the latest buffer/cursor. +//! +//! # Non-bracketed Paste Bursts +//! +//! On some terminals (especially on Windows), pastes arrive as a rapid sequence of +//! `KeyCode::Char` and `KeyCode::Enter` key events instead of a single paste event. +//! +//! To avoid misinterpreting these bursts as real typing, we feed "plain" character events into +//! [`PasteBurst`](super::paste_burst::PasteBurst), which buffers bursts and later flushes them +//! through [`ChatComposer::handle_paste`]. +//! +//! The burst detector intentionally treats ASCII and non-ASCII differently: +//! +//! - ASCII: we briefly hold the first fast char (flicker suppression) until we know whether the +//! stream is paste-like. +//! - non-ASCII: we do not hold the first char (IME input would feel dropped), but we still allow +//! burst detection for actual paste streams. +//! +//! The burst detector can also be disabled (`disable_paste_burst`), which bypasses the state +//! machine and treats the key stream as normal typing. +//! +//! For the detailed burst state machine, see `codex-rs/tui2/src/bottom_pane/paste_burst.rs`. +//! For a narrative overview of the combined state machine, see `docs/tui-chat-composer.md`. +//! +//! # PasteBurst Integration Points +//! +//! The burst detector is consulted in a few specific places: +//! +//! - [`ChatComposer::handle_input_basic`]: flushes any due burst first, then intercepts plain char +//! input to either buffer it or insert normally. +//! - [`ChatComposer::handle_non_ascii_char`]: handles the non-ASCII/IME path without holding the +//! first char, while still allowing paste detection via retro-capture. +//! - [`ChatComposer::flush_paste_burst_if_due`]/[`ChatComposer::handle_paste_burst_flush`]: called +//! from UI ticks to turn a pending burst into either an explicit paste (`handle_paste`) or a +//! normal typed character. +//! +//! # Input Disabled Mode +//! +//! The composer can be temporarily read-only (`input_enabled = false`). In that mode it ignores +//! edits and renders a placeholder prompt instead of the editable textarea. This is part of the +//! overall state machine, since it affects which transitions are even possible from a given UI +//! state. + use crate::key_hint; use crate::key_hint::KeyBinding; use crate::key_hint::has_ctrl_or_alt; @@ -83,6 +141,7 @@ const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000; #[derive(Debug, PartialEq)] pub enum InputResult { Submitted(String), + Queued(String), Command(SlashCommand), CommandWithArgs(SlashCommand, String), None, @@ -124,7 +183,7 @@ pub(crate) struct ChatComposer { /// When false, the composer is temporarily read-only (e.g. during sandbox setup). input_enabled: bool, input_disabled_placeholder: Option, - // Non-bracketed paste burst tracker. + /// Non-bracketed paste burst tracker (see `bottom_pane/paste_burst.rs`). paste_burst: PasteBurst, // When true, disables paste-burst logic and inserts characters immediately. disable_paste_burst: bool, @@ -140,6 +199,7 @@ pub(crate) struct ChatComposer { transcript_copy_feedback: Option, skills: Option>, dismissed_skill_popup_token: Option, + steer_enabled: bool, } /// Popup state – at most one can be visible at any time. @@ -195,6 +255,7 @@ impl ChatComposer { transcript_copy_feedback: None, skills: None, dismissed_skill_popup_token: None, + steer_enabled: false, }; // Apply configuration via the setter to keep side-effects centralized. this.set_disable_paste_burst(disable_paste_burst); @@ -205,6 +266,10 @@ impl ChatComposer { self.skills = skills; } + pub fn set_steer_enabled(&mut self, enabled: bool) { + self.steer_enabled = enabled; + } + fn layout_areas(&self, area: Rect) -> [Rect; 3] { let footer_props = self.footer_props(); let footer_hint_height = self @@ -263,6 +328,24 @@ impl ChatComposer { true } + /// Integrate pasted text into the composer. + /// + /// Acts as the only place where paste text is integrated, both for: + /// + /// - Real/explicit paste events surfaced by the terminal, and + /// - Non-bracketed "paste bursts" that [`PasteBurst`](super::paste_burst::PasteBurst) buffers + /// and later flushes here. + /// + /// Behavior: + /// + /// - If the paste is larger than `LARGE_PASTE_CHAR_THRESHOLD` chars, inserts a placeholder + /// element (expanded on submit) and stores the full text in `pending_pastes`. + /// - Otherwise, if the paste looks like an image path, attaches the image and inserts a + /// trailing space so the user can keep typing naturally. + /// - Otherwise, inserts the pasted text directly into the textarea. + /// + /// In all cases, clears any paste-burst Enter suppression state so a real paste cannot affect + /// the next user Enter key, then syncs popup state. pub fn handle_paste(&mut self, pasted: String) -> bool { let char_count = pasted.chars().count(); if char_count > LARGE_PASTE_CHAR_THRESHOLD { @@ -303,6 +386,16 @@ impl ChatComposer { } } + /// Enable or disable paste-burst handling. + /// + /// `disable_paste_burst` is an escape hatch for terminals/platforms where the burst heuristic + /// is unwanted or has already been handled elsewhere. + /// + /// When enabling the flag we clear the burst classification window so subsequent input cannot + /// be incorrectly grouped into a previous burst. + /// + /// This does not flush any in-progress buffer; callers should avoid toggling this mid-burst + /// (or should flush first). pub(crate) fn set_disable_paste_burst(&mut self, disabled: bool) { let was_disabled = self.disable_paste_burst; self.disable_paste_burst = disabled; @@ -344,7 +437,7 @@ impl ChatComposer { self.textarea.text().to_string() } - /// Attempt to start a burst by retro-capturing recent chars before the cursor. + /// Insert an attachment placeholder and track it for the next submission. pub fn attach_image(&mut self, path: PathBuf) { let image_number = self.attached_images.len() + 1; let placeholder = local_image_label_text(image_number); @@ -360,14 +453,31 @@ impl ChatComposer { images.into_iter().map(|img| img.path).collect() } + /// Flushes any due paste-burst state. + /// + /// Call this from a UI tick to turn paste-burst transient state into explicit textarea edits: + /// + /// - If a burst times out, flush it via `handle_paste(String)`. + /// - If only the first ASCII char was held (flicker suppression) and no burst followed, emit it + /// as normal typed input. + /// + /// This also allows a single "held" ASCII char to render even when it turns out not to be part + /// of a paste burst. pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool { self.handle_paste_burst_flush(Instant::now()) } + /// Returns whether the composer is currently in any paste-burst related transient state. + /// + /// This includes actively buffering, having a non-empty burst buffer, or holding the first + /// ASCII char for flicker suppression. pub(crate) fn is_in_paste_burst(&self) -> bool { self.paste_burst.is_active() } + /// Returns a delay that reliably exceeds the paste-burst timing threshold. + /// + /// Use this in tests to avoid boundary flakiness around the `PasteBurst` timeout. pub(crate) fn recommended_paste_flush_delay() -> Duration { PasteBurst::recommended_flush_delay() } @@ -606,6 +716,20 @@ impl ChatComposer { p } + /// Handle non-ASCII character input (often IME) while still supporting paste-burst detection. + /// + /// This handler exists because non-ASCII input often comes from IMEs, where characters can + /// legitimately arrive in short bursts that should **not** be treated as paste. + /// + /// The key differences from the ASCII path: + /// + /// - We never hold the first character (`PasteBurst::on_plain_char_no_hold`), because holding a + /// non-ASCII char can feel like dropped input. + /// - If a burst is detected, we may need to retroactively remove already-inserted text before + /// the cursor and move it into the paste buffer (see `PasteBurst::decide_begin_buffer`). + /// + /// Because this path mixes "insert immediately" with "maybe retro-grab later", it must clamp + /// the cursor to a UTF-8 char boundary before slicing `textarea.text()`. #[inline] fn handle_non_ascii_char(&mut self, input: KeyEvent) -> (InputResult, bool) { if let KeyEvent { @@ -632,12 +756,13 @@ impl ChatComposer { return (InputResult::None, true); } CharDecision::BeginBuffer { retro_chars } => { + // For non-ASCII we inserted prior chars immediately, so if this turns out + // to be paste-like we need to retroactively grab & remove the already- + // inserted prefix from the textarea before buffering the burst. let cur = self.textarea.cursor(); let txt = self.textarea.text(); let safe_cur = Self::clamp_to_char_boundary(txt, cur); let before = &txt[..safe_cur]; - // If decision is to buffer, seed the paste burst buffer with the grabbed chars + new. - // Otherwise, fall through to normal insertion below. if let Some(grab) = self.paste_burst .decide_begin_buffer(now, before, retro_chars as usize) @@ -649,6 +774,8 @@ impl ChatComposer { self.paste_burst.append_char_to_buffer(ch, now); return (InputResult::None, true); } + // If decide_begin_buffer opted not to start buffering, + // fall through to normal insertion below. } _ => unreachable!("on_plain_char_no_hold returned unexpected variant"), } @@ -1074,6 +1201,199 @@ impl ChatComposer { self.textarea.set_cursor(new_cursor); } + /// Prepare text for submission/queuing. Returns None if submission should be suppressed. + fn prepare_submission_text(&mut self) -> Option { + // If we have pending placeholder pastes, replace them in the textarea text + // and continue to the normal submission flow to handle slash commands. + if !self.pending_pastes.is_empty() { + let mut text = self.textarea.text().to_string(); + for (placeholder, actual) in &self.pending_pastes { + if text.contains(placeholder) { + text = text.replace(placeholder, actual); + } + } + self.textarea.set_text(&text); + self.pending_pastes.clear(); + } + + let mut text = self.textarea.text().to_string(); + let original_input = text.clone(); + let input_starts_with_space = original_input.starts_with(' '); + self.textarea.set_text(""); + + // Replace all pending pastes in the text + for (placeholder, actual) in &self.pending_pastes { + if text.contains(placeholder) { + text = text.replace(placeholder, actual); + } + } + self.pending_pastes.clear(); + + // If there is neither text nor attachments, suppress submission entirely. + let has_attachments = !self.attached_images.is_empty(); + text = text.trim().to_string(); + + if let Some((name, _rest)) = parse_slash_name(&text) { + let treat_as_plain_text = input_starts_with_space || name.contains('/'); + if !treat_as_plain_text { + let is_builtin = built_in_slash_commands() + .into_iter() + .filter(|(_, cmd)| { + windows_degraded_sandbox_active() || *cmd != SlashCommand::ElevateSandbox + }) + .any(|(command_name, _)| command_name == name); + let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:"); + let is_known_prompt = name + .strip_prefix(&prompt_prefix) + .map(|prompt_name| { + self.custom_prompts + .iter() + .any(|prompt| prompt.name == prompt_name) + }) + .unwrap_or(false); + if !is_builtin && !is_known_prompt { + let message = format!( + r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."# + ); + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_info_event(message, None), + ))); + self.textarea.set_text(&original_input); + self.textarea.set_cursor(original_input.len()); + return None; + } + } + } + + let expanded_prompt = match expand_custom_prompt(&text, &self.custom_prompts) { + Ok(expanded) => expanded, + Err(err) => { + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_error_event(err.user_message()), + ))); + self.textarea.set_text(&original_input); + self.textarea.set_cursor(original_input.len()); + return None; + } + }; + if let Some(expanded) = expanded_prompt { + text = expanded; + } + if text.is_empty() && !has_attachments { + return None; + } + if !text.is_empty() { + self.history.record_local_submission(&text); + } + Some(text) + } + + /// Common logic for handling message submission/queuing. + /// Returns the appropriate InputResult based on `should_queue`. + fn handle_submission(&mut self, should_queue: bool) -> (InputResult, bool) { + // If the first line is a bare built-in slash command (no args), + // dispatch it even when the slash popup isn't visible. This preserves + // the workflow: type a prefix ("/di"), press Tab to complete to + // "/diff ", then press Enter/Ctrl+Shift+Q to run it. Tab moves the cursor beyond + // the '/name' token and our caret-based heuristic hides the popup, + // but Enter/Ctrl+Shift+Q should still dispatch the command rather than submit + // literal text. + if let Some(result) = self.try_dispatch_bare_slash_command() { + return (result, true); + } + + // If we're in a paste-like burst capture, treat Enter/Ctrl+Shift+Q as part of the burst + // and accumulate it rather than submitting or inserting immediately. + // Do not treat as paste inside a slash-command context. + let in_slash_context = matches!(self.active_popup, ActivePopup::Command(_)) + || self + .textarea + .text() + .lines() + .next() + .unwrap_or("") + .starts_with('/'); + if self.paste_burst.is_active() && !in_slash_context { + let now = Instant::now(); + if self.paste_burst.append_newline_if_active(now) { + return (InputResult::None, true); + } + } + + // During a paste-like burst, treat Enter/Ctrl+Shift+Q as a newline instead of submit. + let now = Instant::now(); + if self + .paste_burst + .newline_should_insert_instead_of_submit(now) + && !in_slash_context + { + self.textarea.insert_str("\n"); + self.paste_burst.extend_window(now); + return (InputResult::None, true); + } + + let original_input = self.textarea.text().to_string(); + if let Some(result) = self.try_dispatch_slash_command_with_args() { + return (result, true); + } + + if let Some(text) = self.prepare_submission_text() { + if should_queue { + (InputResult::Queued(text), true) + } else { + // Do not clear attached_images here; ChatWidget drains them via take_recent_submission_images(). + (InputResult::Submitted(text), true) + } + } else { + // Restore text if submission was suppressed + self.textarea.set_text(&original_input); + (InputResult::None, true) + } + } + + /// Check if the first line is a bare slash command (no args) and dispatch it. + /// Returns Some(InputResult) if a command was dispatched, None otherwise. + fn try_dispatch_bare_slash_command(&mut self) -> Option { + let first_line = self.textarea.text().lines().next().unwrap_or(""); + if let Some((name, rest)) = parse_slash_name(first_line) + && rest.is_empty() + && let Some((_n, cmd)) = built_in_slash_commands() + .into_iter() + .filter(|(_, cmd)| { + windows_degraded_sandbox_active() || *cmd != SlashCommand::ElevateSandbox + }) + .find(|(n, _)| *n == name) + { + self.textarea.set_text(""); + Some(InputResult::Command(cmd)) + } else { + None + } + } + + /// Check if the input is a slash command with args (e.g., /review args) and dispatch it. + /// Returns Some(InputResult) if a command was dispatched, None otherwise. + fn try_dispatch_slash_command_with_args(&mut self) -> Option { + let original_input = self.textarea.text().to_string(); + let input_starts_with_space = original_input.starts_with(' '); + + if !input_starts_with_space { + let text = self.textarea.text().to_string(); + if let Some((name, rest)) = parse_slash_name(&text) + && !rest.is_empty() + && !name.contains('/') + && let Some((_n, cmd)) = built_in_slash_commands() + .into_iter() + .find(|(command_name, _)| *command_name == name) + && cmd == SlashCommand::Review + { + self.textarea.set_text(""); + return Some(InputResult::CommandWithArgs(cmd, rest.to_string())); + } + } + None + } + /// Handle key event when no popup is visible. fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { if self.handle_shortcut_overlay_key(&key_event) { @@ -1132,161 +1452,41 @@ impl ChatComposer { } self.handle_input_basic(key_event) } + KeyEvent { + code: KeyCode::Tab, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + .. + } => self.handle_submission(true), + KeyEvent { + code: KeyCode::Char('k'), + modifiers: KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + .. + } => { + // Tab queues the message instead of submitting immediately + self.handle_submission(true) + } KeyEvent { code: KeyCode::Enter, modifiers: KeyModifiers::NONE, .. } => { - // If the first line is a bare built-in slash command (no args), - // dispatch it even when the slash popup isn't visible. This preserves - // the workflow: type a prefix ("/di"), press Tab to complete to - // "/diff ", then press Enter to run it. Tab moves the cursor beyond - // the '/name' token and our caret-based heuristic hides the popup, - // but Enter should still dispatch the command rather than submit - // literal text. - let first_line = self.textarea.text().lines().next().unwrap_or(""); - if let Some((name, rest)) = parse_slash_name(first_line) - && rest.is_empty() - && let Some((_n, cmd)) = built_in_slash_commands() - .into_iter() - .filter(|(_, cmd)| { - windows_degraded_sandbox_active() - || *cmd != SlashCommand::ElevateSandbox - }) - .find(|(n, _)| *n == name) - { - self.textarea.set_text(""); - return (InputResult::Command(cmd), true); - } - // If we're in a paste-like burst capture, treat Enter as part of the burst - // and accumulate it rather than submitting or inserting immediately. - // Do not treat Enter as paste inside a slash-command context. - let in_slash_context = matches!(self.active_popup, ActivePopup::Command(_)) - || self - .textarea - .text() - .lines() - .next() - .unwrap_or("") - .starts_with('/'); - if self.paste_burst.is_active() && !in_slash_context { - let now = Instant::now(); - if self.paste_burst.append_newline_if_active(now) { - return (InputResult::None, true); - } - } - // If we have pending placeholder pastes, replace them in the textarea text - // and continue to the normal submission flow to handle slash commands. - if !self.pending_pastes.is_empty() { - let mut text = self.textarea.text().to_string(); - for (placeholder, actual) in &self.pending_pastes { - if text.contains(placeholder) { - text = text.replace(placeholder, actual); - } - } - self.textarea.set_text(&text); - self.pending_pastes.clear(); - } - - // During a paste-like burst, treat Enter as a newline instead of submit. - let now = Instant::now(); - if self - .paste_burst - .newline_should_insert_instead_of_submit(now) - && !in_slash_context - { - self.textarea.insert_str("\n"); - self.paste_burst.extend_window(now); - return (InputResult::None, true); - } - let mut text = self.textarea.text().to_string(); - let original_input = text.clone(); - let input_starts_with_space = original_input.starts_with(' '); - self.textarea.set_text(""); - - // Replace all pending pastes in the text - for (placeholder, actual) in &self.pending_pastes { - if text.contains(placeholder) { - text = text.replace(placeholder, actual); - } - } - self.pending_pastes.clear(); - - // If there is neither text nor attachments, suppress submission entirely. - let has_attachments = !self.attached_images.is_empty(); - text = text.trim().to_string(); - if let Some((name, _rest)) = parse_slash_name(&text) { - let treat_as_plain_text = input_starts_with_space || name.contains('/'); - if !treat_as_plain_text { - let is_builtin = built_in_slash_commands() - .into_iter() - .filter(|(_, cmd)| { - windows_degraded_sandbox_active() - || *cmd != SlashCommand::ElevateSandbox - }) - .any(|(command_name, _)| command_name == name); - let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:"); - let is_known_prompt = name - .strip_prefix(&prompt_prefix) - .map(|prompt_name| { - self.custom_prompts - .iter() - .any(|prompt| prompt.name == prompt_name) - }) - .unwrap_or(false); - if !is_builtin && !is_known_prompt { - let message = format!( - r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."# - ); - self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::new_info_event(message, None), - ))); - self.textarea.set_text(&original_input); - self.textarea.set_cursor(original_input.len()); - return (InputResult::None, true); - } - } - } - - if !input_starts_with_space - && let Some((name, rest)) = parse_slash_name(&text) - && !rest.is_empty() - && !name.contains('/') - && let Some((_n, cmd)) = built_in_slash_commands() - .into_iter() - .find(|(command_name, _)| *command_name == name) - && cmd == SlashCommand::Review - { - return (InputResult::CommandWithArgs(cmd, rest.to_string()), true); - } - - let expanded_prompt = match expand_custom_prompt(&text, &self.custom_prompts) { - Ok(expanded) => expanded, - Err(err) => { - self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::new_error_event(err.user_message()), - ))); - self.textarea.set_text(&original_input); - self.textarea.set_cursor(original_input.len()); - return (InputResult::None, true); - } - }; - if let Some(expanded) = expanded_prompt { - text = expanded; - } - if text.is_empty() && !has_attachments { - return (InputResult::None, true); - } - if !text.is_empty() { - self.history.record_local_submission(&text); - } - // Do not clear attached_images here; ChatWidget drains them via take_recent_submission_images(). - (InputResult::Submitted(text), true) + let should_queue = !self.steer_enabled; + self.handle_submission(should_queue) } input => self.handle_input_basic(input), } } + /// Applies any due `PasteBurst` flush at time `now`. + /// + /// Converts [`PasteBurst::flush_if_due`] results into concrete textarea mutations. + /// + /// Callers: + /// + /// - UI ticks via [`ChatComposer::flush_paste_burst_if_due`], so held first-chars can render. + /// - Input handling via [`ChatComposer::handle_input_basic`], so a due burst does not lag. fn handle_paste_burst_flush(&mut self, now: Instant) -> bool { match self.paste_burst.flush_if_due(now) { FlushResult::Paste(pasted) => { @@ -1304,7 +1504,20 @@ impl ChatComposer { } } - /// Handle generic Input events that modify the textarea content. + /// Handles keys that mutate the textarea, including paste-burst detection. + /// + /// Acts as the lowest-level keypath for keys that mutate the textarea. It is also where plain + /// character streams are converted into explicit paste operations on terminals that do not + /// reliably provide bracketed paste. + /// + /// Ordering is important: + /// + /// - Always flush any *due* paste burst first so buffered text does not lag behind unrelated + /// edits. + /// - Then handle the incoming key, intercepting only "plain" (no Ctrl/Alt) char input. + /// - For non-plain keys, flush via `flush_before_modified_input()` before applying the key; + /// otherwise `clear_window_after_non_char()` can leave buffered text waiting without a + /// timestamp to time out against. fn handle_input_basic(&mut self, input: KeyEvent) -> (InputResult, bool) { // If we have a buffered non-bracketed paste burst and enough time has // elapsed since the last char, flush it before handling a new input. @@ -1324,6 +1537,10 @@ impl ChatComposer { } // Intercept plain Char inputs to optionally accumulate into a burst buffer. + // + // This is intentionally limited to "plain" (no Ctrl/Alt) chars so shortcuts keep their + // normal semantics, and so we can aggressively flush/clear any burst state when non-char + // keys are pressed. if let KeyEvent { code: KeyCode::Char(ch), modifiers, @@ -1511,6 +1728,7 @@ impl ChatComposer { esc_backtrack_hint: self.esc_backtrack_hint, use_shift_enter_hint: self.use_shift_enter_hint, is_task_running: self.is_task_running, + steer_enabled: self.steer_enabled, context_window_percent: self.context_window_percent, context_window_used_tokens: self.context_window_used_tokens, transcript_scrolled: self.transcript_scrolled, @@ -2177,6 +2395,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); composer.set_text_content("draft text".to_string()); assert_eq!(composer.clear_for_ctrl_c(), Some("draft text".to_string())); @@ -2203,6 +2422,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); let (result, needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); @@ -2243,6 +2463,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); // Force an active paste burst so this test doesn't depend on tight timing. composer @@ -2277,6 +2498,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); assert_eq!(composer.footer_mode, FooterMode::ShortcutOverlay); @@ -2451,6 +2673,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)); assert!(composer.is_in_paste_burst()); @@ -2528,6 +2751,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); // Force an active burst so this test doesn't depend on tight timing. composer @@ -2567,6 +2791,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); let needs_redraw = composer.handle_paste("hello".to_string()); assert!(needs_redraw); @@ -2596,6 +2821,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); // Ensure composer is empty and press Enter. assert!(composer.textarea.text().is_empty()); @@ -2623,6 +2849,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); let large = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 10); let needs_redraw = composer.handle_paste(large.clone()); @@ -2658,6 +2885,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); composer.handle_paste(large); assert_eq!(composer.pending_pastes.len(), 1); @@ -2742,6 +2970,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); // Type "/mo" humanlike so paste-burst doesn’t interfere. type_chars_humanlike(&mut composer, &['/', 'm', 'o']); @@ -2770,6 +2999,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); type_chars_humanlike(&mut composer, &['/', 'm', 'o']); match &composer.active_popup { @@ -2801,6 +3031,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); // Type "/res" humanlike so paste-burst doesn’t interfere. type_chars_humanlike(&mut composer, &['/', 'r', 'e', 's']); @@ -2826,6 +3057,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); type_chars_humanlike(&mut composer, &['/', 'r', 'e', 's']); match &composer.active_popup { @@ -2894,6 +3126,9 @@ mod tests { InputResult::Submitted(text) => { panic!("expected command dispatch, but composer submitted literal text: {text}") } + InputResult::Queued(_) => { + panic!("expected command dispatch, but composer queued literal text") + } InputResult::None => panic!("expected Command result for '/init'"), } assert!(composer.textarea.is_empty(), "composer should be cleared"); @@ -2970,6 +3205,9 @@ mod tests { InputResult::Submitted(text) => { panic!("expected command dispatch after Tab completion, got literal submit: {text}") } + InputResult::Queued(_) => { + panic!("expected command dispatch after Tab completion, got literal queue") + } InputResult::None => panic!("expected Command result for '/diff'"), } assert!(composer.textarea.is_empty()); @@ -3006,6 +3244,9 @@ mod tests { InputResult::Submitted(text) => { panic!("expected command dispatch, but composer submitted literal text: {text}") } + InputResult::Queued(_) => { + panic!("expected command dispatch, but composer queued literal text") + } InputResult::None => panic!("expected Command result for '/mention'"), } assert!(composer.textarea.is_empty(), "composer should be cleared"); @@ -3028,6 +3269,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); // Define test cases: (paste content, is_large) let test_cases = [ @@ -3304,6 +3546,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); let path = PathBuf::from("/tmp/image1.png"); composer.attach_image(path.clone()); composer.handle_paste(" hi".into()); @@ -3328,6 +3571,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); let path = PathBuf::from("/tmp/image2.png"); composer.attach_image(path.clone()); let (result, _) = @@ -3535,6 +3779,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); // Inject prompts as if received via event. composer.set_custom_prompts(vec![CustomPrompt { @@ -3571,6 +3816,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); composer.set_custom_prompts(vec![CustomPrompt { name: "my-prompt".to_string(), @@ -3605,6 +3851,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); composer.set_custom_prompts(vec![CustomPrompt { name: "my-prompt".to_string(), @@ -3643,6 +3890,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); // Create a custom prompt with positional args (no named args like $USER) composer.set_custom_prompts(vec![CustomPrompt { @@ -3707,6 +3955,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); composer .textarea @@ -3743,6 +3992,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); composer.textarea.set_text(" /this-looks-like-a-command"); @@ -3876,6 +4126,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); composer.set_custom_prompts(vec![CustomPrompt { name: "my-prompt".to_string(), @@ -3913,6 +4164,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); composer.set_custom_prompts(vec![CustomPrompt { name: "elegant".to_string(), @@ -3980,6 +4232,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); composer.set_custom_prompts(vec![CustomPrompt { name: "price".to_string(), @@ -4017,6 +4270,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); composer.set_custom_prompts(vec![CustomPrompt { name: "repeat".to_string(), diff --git a/codex-rs/tui2/src/bottom_pane/footer.rs b/codex-rs/tui2/src/bottom_pane/footer.rs index 49b37633e6..c543ab6ee3 100644 --- a/codex-rs/tui2/src/bottom_pane/footer.rs +++ b/codex-rs/tui2/src/bottom_pane/footer.rs @@ -21,6 +21,7 @@ pub(crate) struct FooterProps { pub(crate) esc_backtrack_hint: bool, pub(crate) use_shift_enter_hint: bool, pub(crate) is_task_running: bool, + pub(crate) steer_enabled: bool, pub(crate) context_window_percent: Option, pub(crate) context_window_used_tokens: Option, pub(crate) transcript_scrolled: bool, @@ -152,10 +153,18 @@ fn footer_lines(props: FooterProps) -> Vec> { shortcut_overlay_lines(state) } FooterMode::EscHint => vec![esc_hint_line(props.esc_backtrack_hint)], - FooterMode::ContextOnly => vec![context_window_line( - props.context_window_percent, - props.context_window_used_tokens, - )], + FooterMode::ContextOnly => { + let mut line = context_window_line( + props.context_window_percent, + props.context_window_used_tokens, + ); + if props.is_task_running && props.steer_enabled { + line.push_span(" · ".dim()); + line.push_span(key_hint::plain(KeyCode::Tab)); + line.push_span(" to queue message".dim()); + } + vec![line] + } }; apply_copy_feedback(&mut lines, props.transcript_copy_feedback); lines @@ -205,6 +214,7 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { let mut commands = Line::from(""); let mut shell_commands = Line::from(""); let mut newline = Line::from(""); + let mut queue_message_tab = Line::from(""); let mut file_paths = Line::from(""); let mut paste_image = Line::from(""); let mut edit_previous = Line::from(""); @@ -217,6 +227,7 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { ShortcutId::Commands => commands = text, ShortcutId::ShellCommands => shell_commands = text, ShortcutId::InsertNewline => newline = text, + ShortcutId::QueueMessageTab => queue_message_tab = text, ShortcutId::FilePaths => file_paths = text, ShortcutId::PasteImage => paste_image = text, ShortcutId::EditPrevious => edit_previous = text, @@ -230,6 +241,7 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { commands, shell_commands, newline, + queue_message_tab, file_paths, paste_image, edit_previous, @@ -307,6 +319,7 @@ enum ShortcutId { Commands, ShellCommands, InsertNewline, + QueueMessageTab, FilePaths, PasteImage, EditPrevious, @@ -412,6 +425,15 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ prefix: "", label: " for newline", }, + ShortcutDescriptor { + id: ShortcutId::QueueMessageTab, + bindings: &[ShortcutBinding { + key: key_hint::plain(KeyCode::Tab), + condition: DisplayCondition::Always, + }], + prefix: "", + label: " to queue message", + }, ShortcutDescriptor { id: ShortcutId::FilePaths, bindings: &[ShortcutBinding { @@ -495,6 +517,7 @@ mod tests { esc_backtrack_hint: false, use_shift_enter_hint: false, is_task_running: false, + steer_enabled: false, context_window_percent: None, context_window_used_tokens: None, transcript_scrolled: false, @@ -512,6 +535,7 @@ mod tests { esc_backtrack_hint: false, use_shift_enter_hint: false, is_task_running: false, + steer_enabled: false, context_window_percent: None, context_window_used_tokens: None, transcript_scrolled: true, @@ -529,6 +553,7 @@ mod tests { esc_backtrack_hint: true, use_shift_enter_hint: true, is_task_running: false, + steer_enabled: false, context_window_percent: None, context_window_used_tokens: None, transcript_scrolled: false, @@ -546,6 +571,7 @@ mod tests { esc_backtrack_hint: false, use_shift_enter_hint: false, is_task_running: false, + steer_enabled: false, context_window_percent: None, context_window_used_tokens: None, transcript_scrolled: false, @@ -563,6 +589,7 @@ mod tests { esc_backtrack_hint: false, use_shift_enter_hint: false, is_task_running: true, + steer_enabled: false, context_window_percent: None, context_window_used_tokens: None, transcript_scrolled: false, @@ -580,6 +607,7 @@ mod tests { esc_backtrack_hint: false, use_shift_enter_hint: false, is_task_running: false, + steer_enabled: false, context_window_percent: None, context_window_used_tokens: None, transcript_scrolled: false, @@ -597,6 +625,7 @@ mod tests { esc_backtrack_hint: true, use_shift_enter_hint: false, is_task_running: false, + steer_enabled: false, context_window_percent: None, context_window_used_tokens: None, transcript_scrolled: false, @@ -614,6 +643,7 @@ mod tests { esc_backtrack_hint: false, use_shift_enter_hint: false, is_task_running: true, + steer_enabled: false, context_window_percent: Some(72), context_window_used_tokens: None, transcript_scrolled: false, @@ -631,6 +661,7 @@ mod tests { esc_backtrack_hint: false, use_shift_enter_hint: false, is_task_running: false, + steer_enabled: false, context_window_percent: None, context_window_used_tokens: Some(123_456), transcript_scrolled: false, @@ -641,6 +672,42 @@ mod tests { }, ); + snapshot_footer( + "footer_context_only_queue_hint_disabled", + FooterProps { + mode: FooterMode::ContextOnly, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: true, + steer_enabled: false, + context_window_percent: None, + context_window_used_tokens: None, + transcript_scrolled: false, + transcript_selection_active: false, + transcript_scroll_position: None, + transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), + transcript_copy_feedback: None, + }, + ); + + snapshot_footer( + "footer_context_only_queue_hint_enabled", + FooterProps { + mode: FooterMode::ContextOnly, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: true, + steer_enabled: true, + context_window_percent: None, + context_window_used_tokens: None, + transcript_scrolled: false, + transcript_selection_active: false, + transcript_scroll_position: None, + transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), + transcript_copy_feedback: None, + }, + ); + snapshot_footer( "footer_copy_feedback_copied", FooterProps { @@ -648,6 +715,7 @@ mod tests { esc_backtrack_hint: false, use_shift_enter_hint: false, is_task_running: false, + steer_enabled: false, context_window_percent: None, context_window_used_tokens: None, transcript_scrolled: false, diff --git a/codex-rs/tui2/src/bottom_pane/mod.rs b/codex-rs/tui2/src/bottom_pane/mod.rs index 887fb3e64d..bbf5e8849d 100644 --- a/codex-rs/tui2/src/bottom_pane/mod.rs +++ b/codex-rs/tui2/src/bottom_pane/mod.rs @@ -137,6 +137,10 @@ impl BottomPane { self.request_redraw(); } + pub fn set_steer_enabled(&mut self, enabled: bool) { + self.composer.set_steer_enabled(enabled); + } + pub fn status_widget(&self) -> Option<&StatusIndicatorWidget> { self.status.as_ref() } diff --git a/codex-rs/tui2/src/bottom_pane/paste_burst.rs b/codex-rs/tui2/src/bottom_pane/paste_burst.rs index 96ed095b8f..ae0234f1a7 100644 --- a/codex-rs/tui2/src/bottom_pane/paste_burst.rs +++ b/codex-rs/tui2/src/bottom_pane/paste_burst.rs @@ -1,3 +1,150 @@ +//! Paste-burst detection for terminals without bracketed paste. +//! +//! On some platforms (notably Windows), pastes often arrive as a rapid stream of +//! `KeyCode::Char` and `KeyCode::Enter` key events rather than as a single "paste" event. +//! In that mode, the composer needs to: +//! +//! - Prevent transient UI side effects (e.g. toggles bound to `?`) from triggering on pasted text. +//! - Ensure Enter is treated as a newline *inside the paste*, not as "submit the message". +//! - Avoid flicker caused by inserting a typed prefix and then immediately reclassifying it as +//! paste once enough chars have arrived. +//! +//! This module provides the `PasteBurst` state machine. `ChatComposer` feeds it only "plain" +//! character events (no Ctrl/Alt) and uses its decisions to either: +//! +//! - briefly hold a first ASCII char (flicker suppression), +//! - buffer a burst as a single pasted string, or +//! - let input flow through as normal typing. +//! +//! For the higher-level view of how `PasteBurst` integrates with `ChatComposer`, see +//! `docs/tui-chat-composer.md`. +//! +//! # Call Pattern +//! +//! `PasteBurst` is a pure state machine: it never mutates the textarea directly. The caller feeds +//! it events and then applies the chosen action: +//! +//! - For each plain `KeyCode::Char`, call [`PasteBurst::on_plain_char`] (ASCII) or +//! [`PasteBurst::on_plain_char_no_hold`] (non-ASCII/IME). +//! - If the decision indicates buffering, the caller appends to `PasteBurst.buffer` via +//! [`PasteBurst::append_char_to_buffer`]. +//! - On a UI tick, call [`PasteBurst::flush_if_due`]. If it returns [`FlushResult::Typed`], insert +//! that char as normal typing. If it returns [`FlushResult::Paste`], treat the returned string as +//! an explicit paste. +//! - Before applying non-char input (arrow keys, Ctrl/Alt modifiers, etc.), use +//! [`PasteBurst::flush_before_modified_input`] to avoid leaving buffered text "stuck", and then +//! [`PasteBurst::clear_window_after_non_char`] so subsequent typing does not get grouped into a +//! previous burst. +//! +//! # State Variables +//! +//! This state machine is encoded in a few fields with slightly different meanings: +//! +//! - `active`: true while we are still *actively* accepting characters into the current burst. +//! - `buffer`: accumulated burst text that will eventually flush as a single `Paste(String)`. +//! A non-empty buffer is treated as "in burst context" even if `active` has been cleared. +//! - `pending_first_char`: a single held ASCII char used for flicker suppression. The caller must +//! not render this char until it either becomes part of a burst (`BeginBufferFromPending`) or +//! flushes as a normal typed char (`FlushResult::Typed`). +//! - `last_plain_char_time`/`consecutive_plain_char_burst`: the timing/count heuristic for +//! "paste-like" streams. +//! - `burst_window_until`: the Enter suppression window ("Enter inserts newline") that outlives the +//! buffer itself. +//! +//! # Timing Model +//! +//! There are two timeouts: +//! +//! - `PASTE_BURST_CHAR_INTERVAL`: maximum delay between consecutive "plain" chars for them to be +//! considered part of a single burst. It also bounds how long `pending_first_char` is held. +//! - `PASTE_BURST_ACTIVE_IDLE_TIMEOUT`: once buffering is active, how long to wait after the last +//! char before flushing the accumulated buffer as a paste. +//! +//! `flush_if_due()` intentionally uses `>` (not `>=`) when comparing elapsed time, so tests and UI +//! ticks should cross the threshold by at least 1ms (see `recommended_flush_delay()`). +//! +//! # Retro Capture Details +//! +//! Retro-capture exists to handle the case where we initially inserted characters as "normal +//! typing", but later decide that the stream is paste-like. When that happens, we retroactively +//! remove a prefix of already-inserted text from the textarea and move it into the burst buffer so +//! the eventual `handle_paste(...)` sees a contiguous pasted string. +//! +//! Retro-capture mostly matters on paths that do *not* hold the first character (non-ASCII/IME +//! input, and retro-grab scenarios). The ASCII path usually prefers +//! `RetainFirstChar -> BeginBufferFromPending`, which avoids needing retro-capture at all. +//! +//! Retro-capture is expressed in terms of characters, not bytes: +//! +//! - `CharDecision::BeginBuffer { retro_chars }` uses `retro_chars` as a character count. +//! - `decide_begin_buffer(now, before_cursor, retro_chars)` turns that into a UTF-8 byte range by +//! calling `retro_start_index()`. +//! - `RetroGrab.start_byte` is a byte index into the `before_cursor` slice; callers must clamp the +//! cursor to a char boundary before slicing so `start_byte..cursor` is always valid UTF-8. +//! +//! # Clearing vs Flushing +//! +//! There are two ways callers end burst handling, and they are not interchangeable: +//! +//! - `flush_before_modified_input()` returns the buffered text (and/or a pending first ASCII char) +//! so the caller can apply it through the normal paste path before handling an unrelated input. +//! - `clear_window_after_non_char()` clears the *classification window* so subsequent typing does +//! not get grouped into the previous burst. It assumes the caller has already flushed any buffer +//! because it clears `last_plain_char_time`, which means `flush_if_due()` will not flush a +//! non-empty buffer until another plain char updates the timestamp. +//! +//! # States (Conceptually) +//! +//! - **Idle**: no buffered text, no pending char. +//! - **Pending first char**: `pending_first_char` holds one ASCII char for up to +//! `PASTE_BURST_CHAR_INTERVAL` while we wait to see if a burst follows. +//! - **Active buffer**: `active`/`buffer` holds paste-like content until it times out and flushes. +//! - **Enter suppress window**: `burst_window_until` keeps Enter treated as newline briefly after +//! burst activity so multiline pastes stay grouped. +//! +//! # ASCII vs Non-ASCII +//! +//! - [`PasteBurst::on_plain_char`] may return [`CharDecision::RetainFirstChar`] to hold the first +//! ASCII char and avoid flicker. +//! - [`PasteBurst::on_plain_char_no_hold`] never holds (used for IME/non-ASCII paths), since +//! holding a non-ASCII character can feel like dropped input. +//! +//! # Contract With `ChatComposer` +//! +//! `PasteBurst` does not mutate the UI text buffer on its own. The caller (`ChatComposer`) must +//! interpret decisions and apply the corresponding UI edits: +//! +//! - For each plain ASCII `KeyCode::Char`, call [`PasteBurst::on_plain_char`]. +//! - [`CharDecision::RetainFirstChar`]: do **not** insert the char into the textarea yet. +//! - [`CharDecision::BeginBufferFromPending`]: call [`PasteBurst::append_char_to_buffer`] for the +//! current char (the previously-held char is already in the burst buffer). +//! - [`CharDecision::BeginBuffer { retro_chars }`]: consider retro-capturing the already-inserted +//! prefix by calling [`PasteBurst::decide_begin_buffer`]. If it returns `Some`, remove the +//! returned `start_byte..cursor` range from the textarea and then call +//! [`PasteBurst::append_char_to_buffer`] for the current char. If it returns `None`, fall back +//! to normal insertion. +//! - [`CharDecision::BufferAppend`]: call [`PasteBurst::append_char_to_buffer`]. +//! +//! - For each plain non-ASCII `KeyCode::Char`, call [`PasteBurst::on_plain_char_no_hold`] and then: +//! - If it returns `Some(CharDecision::BufferAppend)`, call +//! [`PasteBurst::append_char_to_buffer`]. +//! - If it returns `Some(CharDecision::BeginBuffer { retro_chars })`, call +//! [`PasteBurst::decide_begin_buffer`] as above (and if buffering starts, remove the grabbed +//! prefix from the textarea and then append the current char to the buffer). +//! - If it returns `None`, insert normally. +//! +//! - Before applying non-char input (or any input that should not join a burst), call +//! [`PasteBurst::flush_before_modified_input`] and pass the returned string (if any) through the +//! normal paste path. +//! +//! - Periodically (e.g. on a UI tick), call [`PasteBurst::flush_if_due`]. +//! - [`FlushResult::Typed`]: insert that single char as normal typing. +//! - [`FlushResult::Paste`]: treat the returned string as an explicit paste. +//! +//! - When a non-plain key is pressed (Ctrl/Alt-modified input, arrows, etc.), callers should use +//! [`PasteBurst::clear_window_after_non_char`] to prevent the next keystroke from being +//! incorrectly grouped into a previous burst. + use std::time::Duration; use std::time::Instant; @@ -130,15 +277,15 @@ impl PasteBurst { self.last_plain_char_time = Some(now); } - /// Flush the buffered burst if the inter-key timeout has elapsed. + /// Flushes any buffered burst if the inter-key timeout has elapsed. /// - /// Returns Some(String) when either: - /// - We were actively buffering paste-like input and the buffer is now - /// emitted as a single pasted string; or - /// - We had saved a single fast first-char with no subsequent burst and we - /// now emit that char as normal typed input. + /// Returns: /// - /// Returns None if the timeout has not elapsed or there is nothing to flush. + /// - [`FlushResult::Paste`] when a paste burst was active and buffered text is emitted as one + /// pasted string. + /// - [`FlushResult::Typed`] when a single fast first ASCII char was being held (flicker + /// suppression) and no burst followed before the timeout elapsed. + /// - [`FlushResult::None`] when the timeout has not elapsed, or there is nothing to flush. pub fn flush_if_due(&mut self, now: Instant) -> FlushResult { let timeout = if self.is_active_internal() { PASTE_BURST_ACTIVE_IDLE_TIMEOUT diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap index 2bc29097f9..be9e62f40c 100644 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap @@ -1,6 +1,5 @@ --- source: tui2/src/bottom_pane/chat_composer.rs -assertion_line: 2093 expression: terminal.backend() --- " " @@ -11,8 +10,8 @@ expression: terminal.backend() " " " " " " -" / for commands ! for shell commands " -" shift + enter for newline @ for file paths " -" ctrl + v to paste images esc again to edit previous message " -" ctrl + c to exit " -" ctrl + t to view transcript " +" / for commands ! for shell commands " +" shift + enter for newline tab to queue message " +" @ for file paths ctrl + v to paste images " +" esc again to edit previous message ctrl + c to exit " +" ctrl + t to view transcript " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_context_only_queue_hint_disabled.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_context_only_queue_hint_disabled.snap new file mode 100644 index 0000000000..09d51329ad --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_context_only_queue_hint_disabled.snap @@ -0,0 +1,5 @@ +--- +source: tui2/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" 100% context left " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_context_only_queue_hint_enabled.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_context_only_queue_hint_enabled.snap new file mode 100644 index 0000000000..6f985f20cc --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_context_only_queue_hint_enabled.snap @@ -0,0 +1,5 @@ +--- +source: tui2/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" 100% context left · tab to queue message " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap index 7863ca945f..ba860d1f48 100644 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap @@ -1,10 +1,9 @@ --- source: tui2/src/bottom_pane/footer.rs -assertion_line: 486 expression: terminal.backend() --- -" / for commands ! for shell commands " -" shift + enter for newline @ for file paths " -" ctrl + v to paste images esc again to edit previous message " -" ctrl + c to exit " -" ctrl + t to view transcript " +" / for commands ! for shell commands " +" shift + enter for newline tab to queue message " +" @ for file paths ctrl + v to paste images " +" esc again to edit previous message ctrl + c to exit " +" ctrl + t to view transcript " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap index 3b6782d06d..0272440027 100644 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap @@ -9,8 +9,9 @@ expression: terminal.backend() " " " " " " -" " -" / for commands shift + enter for newline " -" @ for file paths ctrl + v to paste images " -" esc again to edit previous message ctrl + c to exit " -" ctrl + t to view transcript " +" / for commands ! for shell commands " +" shift + enter for newline tab to queue message " +" @ for file paths ctrl + v to paste images " +" ctrl + g to edit in external editor esc again to edit previous message " +" ctrl + c to exit " +" ctrl + t to view transcript " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap index 264515a6c2..c1f00d4437 100644 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap @@ -2,7 +2,9 @@ source: tui/src/bottom_pane/footer.rs expression: terminal.backend() --- -" / for commands shift + enter for newline " -" @ for file paths ctrl + v to paste images " -" esc again to edit previous message ctrl + c to exit " -" ctrl + t to view transcript " +" / for commands ! for shell commands " +" shift + enter for newline tab to queue message " +" @ for file paths ctrl + v to paste images " +" ctrl + g to edit in external editor esc again to edit previous message " +" ctrl + c to exit " +" ctrl + t to view transcript " diff --git a/codex-rs/tui2/src/bottom_pane/textarea.rs b/codex-rs/tui2/src/bottom_pane/textarea.rs index f2ed40758d..903ebe9f82 100644 --- a/codex-rs/tui2/src/bottom_pane/textarea.rs +++ b/codex-rs/tui2/src/bottom_pane/textarea.rs @@ -730,8 +730,8 @@ impl TextArea { /// Renames a single text element in-place, keeping it atomic. /// - /// This is intended for cases where the element payload is an identifier (e.g. a placeholder) - /// that must be updated without converting the element back into normal text. + /// Use this when the element payload is an identifier (e.g. a placeholder) that must be + /// updated without converting the element back into normal text. pub fn replace_element_payload(&mut self, old: &str, new: &str) -> bool { let Some(idx) = self .elements diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index c0ea542177..66eebde6b3 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -1,3 +1,20 @@ +//! The main Codex TUI chat surface. +//! +//! `ChatWidget` consumes protocol events, builds and updates history cells, and drives rendering +//! for both the main viewport and overlay UIs. +//! +//! The UI has both committed transcript cells (finalized `HistoryCell`s) and an in-flight active +//! cell (`ChatWidget.active_cell`) that can mutate in place while streaming (often representing a +//! coalesced exec/tool group). The transcript overlay (`Ctrl+T`) renders committed cells plus a +//! cached, render-only live tail derived from the current active cell so in-flight tool calls are +//! visible immediately. +//! +//! The transcript overlay is kept in sync by `App::overlay_forward_event`, which syncs a live tail +//! during draws using `active_cell_transcript_key()` and `active_cell_transcript_lines()`. The +//! cache key is designed to change when the active cell mutates in place or when its transcript +//! output is time-dependent so the overlay can refresh its cached tail without rebuilding it on +//! every draw. + use std::collections::HashMap; use std::collections::HashSet; use std::collections::VecDeque; @@ -286,6 +303,16 @@ pub(crate) struct ChatWidget { codex_op_tx: UnboundedSender, bottom_pane: BottomPane, active_cell: Option>, + /// Monotonic-ish counter used to invalidate transcript overlay caching. + /// + /// The transcript overlay appends a cached "live tail" for the current active cell. Most + /// active-cell updates are mutations of the *existing* cell (not a replacement), so pointer + /// identity alone is not a good cache key. + /// + /// Callers bump this whenever the active cell's transcript output could change without + /// flushing. It is intentionally allowed to wrap, which implies a rare one-time cache collision + /// where the overlay may briefly treat new tail content as already cached. + active_cell_revision: u64, config: Config, model: String, auth_manager: Arc, @@ -340,6 +367,30 @@ pub(crate) struct ChatWidget { current_rollout_path: Option, } +/// Snapshot of active-cell state that affects transcript overlay rendering. +/// +/// The overlay keeps a cached "live tail" for the in-flight cell; this key lets +/// it cheaply decide when to recompute that tail as the active cell evolves. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct ActiveCellTranscriptKey { + /// Cache-busting revision for in-place updates. + /// + /// Many active cells are updated incrementally while streaming (for example when exec groups + /// add output or change status), and the transcript overlay caches its live tail, so this + /// revision gives a cheap way to say "same active cell, but its transcript output is different + /// now". Callers bump it on any mutation that can affect `HistoryCell::transcript_lines`. + pub(crate) revision: u64, + /// Whether the active cell continues the prior stream, which affects + /// spacing between transcript blocks. + pub(crate) is_stream_continuation: bool, + /// Optional animation tick for time-dependent transcript output. + /// + /// When this changes, the overlay recomputes the cached tail even if the revision and width + /// are unchanged, which is how shimmer/spinner visuals can animate in the overlay without any + /// underlying data change. + pub(crate) animation_tick: Option, +} + struct UserMessage { text: String, image_paths: Vec, @@ -1091,6 +1142,9 @@ impl ChatWidget { cell.complete_call(&ev.call_id, output, ev.duration); if cell.should_flush() { self.flush_active_cell(); + } else { + self.bump_active_cell_revision(); + self.request_redraw(); } } } @@ -1207,6 +1261,7 @@ impl ChatWidget { ) { *cell = new_exec; + self.bump_active_cell_revision(); } else { self.flush_active_cell(); @@ -1218,6 +1273,7 @@ impl ChatWidget { interaction_input, self.config.animations, ))); + self.bump_active_cell_revision(); } self.request_redraw(); @@ -1231,6 +1287,7 @@ impl ChatWidget { ev.invocation, self.config.animations, ))); + self.bump_active_cell_revision(); self.request_redraw(); } pub(crate) fn handle_mcp_end_now(&mut self, ev: McpToolCallEndEvent) { @@ -1303,6 +1360,7 @@ impl ChatWidget { skills: None, }), active_cell: None, + active_cell_revision: 0, config, model: model.clone(), auth_manager, @@ -1343,6 +1401,9 @@ impl ChatWidget { }; widget.prefetch_rate_limits(); + widget + .bottom_pane + .set_steer_enabled(widget.config.features.enabled(Feature::Steer)); widget } @@ -1387,6 +1448,7 @@ impl ChatWidget { skills: None, }), active_cell: None, + active_cell_revision: 0, config, model: model.clone(), auth_manager, @@ -1427,6 +1489,9 @@ impl ChatWidget { }; widget.prefetch_rate_limits(); + widget + .bottom_pane + .set_steer_enabled(widget.config.features.enabled(Feature::Steer)); widget } @@ -1492,7 +1557,19 @@ impl ChatWidget { _ => { match self.bottom_pane.handle_key_event(key_event) { InputResult::Submitted(text) => { - // If a task is running, queue the user input to be sent after the turn completes. + // Enter always sends messages immediately (bypasses queue check) + // Clear any reasoning status header when submitting a new message + self.reasoning_buffer.clear(); + self.full_reasoning_buffer.clear(); + self.set_status_header(String::from("Working")); + let user_message = UserMessage { + text, + image_paths: self.bottom_pane.take_recent_submission_images(), + }; + self.submit_user_message(user_message); + } + InputResult::Queued(text) => { + // Tab queues the message if a task is running, otherwise submits immediately let user_message = UserMessage { text, image_paths: self.bottom_pane.take_recent_submission_images(), @@ -1778,6 +1855,7 @@ impl ChatWidget { self.app_event_tx.send(AppEvent::InsertHistoryCell(cell)); } + #[allow(dead_code)] // Used in tests fn queue_user_message(&mut self, user_message: UserMessage) { if self.bottom_pane.is_task_running() { self.queued_user_messages.push_back(user_message); @@ -2052,9 +2130,12 @@ impl ChatWidget { fn on_user_message_event(&mut self, event: UserMessageEvent) { let message = event.message.trim(); + // Only show the text portion in conversation history. if !message.is_empty() { self.add_to_history(history_cell::new_user_prompt(message.to_string())); } + + self.needs_final_message_separator = false; } fn request_exit(&self) { @@ -2065,6 +2146,12 @@ impl ChatWidget { self.frame_requester.schedule_frame(); } + fn bump_active_cell_revision(&mut self) { + // Wrapping avoids overflow; wraparound would require 2^64 bumps and at + // worst causes a one-time cache-key collision. + self.active_cell_revision = self.active_cell_revision.wrapping_add(1); + } + fn notify(&mut self, notification: Notification) { if !notification.allowed_for(&self.config.tui_notifications) { return; @@ -3297,6 +3384,9 @@ impl ChatWidget { } else { self.config.features.disable(feature); } + if feature == Feature::Steer { + self.bottom_pane.set_steer_enabled(enabled); + } } pub(crate) fn set_full_access_warning_acknowledged(&mut self, acknowledged: bool) { @@ -3638,6 +3728,37 @@ impl ChatWidget { self.current_rollout_path.clone() } + /// Returns a cache key describing the current in-flight active cell for the transcript overlay. + /// + /// `Ctrl+T` renders committed transcript cells plus a render-only live tail derived from the + /// current active cell, and the overlay caches that tail; this key is what it uses to decide + /// whether it must recompute. When there is no active cell, this returns `None` so the overlay + /// can drop the tail entirely. + /// + /// If callers mutate the active cell's transcript output without bumping the revision (or + /// providing an appropriate animation tick), the overlay will keep showing a stale tail while + /// the main viewport updates. + pub(crate) fn active_cell_transcript_key(&self) -> Option { + let cell = self.active_cell.as_ref()?; + Some(ActiveCellTranscriptKey { + revision: self.active_cell_revision, + is_stream_continuation: cell.is_stream_continuation(), + animation_tick: cell.transcript_animation_tick(), + }) + } + + /// Returns the active cell's transcript lines for a given terminal width. + /// + /// This is a convenience for the transcript overlay live-tail path, and it intentionally + /// filters out empty results so the overlay can treat "nothing to render" as "no tail". Callers + /// should pass the same width the overlay uses; using a different width will cause wrapping + /// mismatches between the main viewport and the transcript overlay. + pub(crate) fn active_cell_transcript_lines(&self, width: u16) -> Option>> { + let cell = self.active_cell.as_ref()?; + let lines = cell.transcript_lines(width); + (!lines.is_empty()).then_some(lines) + } + /// Return a reference to the widget's current config (includes any /// runtime overrides applied via TUI, e.g., model or approval policy). pub(crate) fn config_ref(&self) -> &Config { diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap index a447b748bb..c7d5535723 100644 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap @@ -2,6 +2,33 @@ source: tui2/src/chatwidget/tests.rs expression: term.backend().vt100().screen().contents() --- + + + + + + + + + + + + + + + + + + + + + + + + + + + • I’m going to search the repo for where “Change Approved” is rendered to update that view. @@ -14,4 +41,4 @@ expression: term.backend().vt100().screen().contents() › Summarize recent commits - 100% context left + 100% context left · tab to queue message diff --git a/codex-rs/tui2/src/chatwidget/tests.rs b/codex-rs/tui2/src/chatwidget/tests.rs index 2d847c84ac..32dce6b648 100644 --- a/codex-rs/tui2/src/chatwidget/tests.rs +++ b/codex-rs/tui2/src/chatwidget/tests.rs @@ -361,7 +361,7 @@ async fn make_chatwidget_manual( if let Some(model) = model_override { cfg.model = Some(model.to_string()); } - let bottom = BottomPane::new(BottomPaneParams { + let mut bottom = BottomPane::new(BottomPaneParams { app_event_tx: app_event_tx.clone(), frame_requester: FrameRequester::test_dummy(), has_input_focus: true, @@ -371,6 +371,7 @@ async fn make_chatwidget_manual( animations_enabled: cfg.animations, skills: None, }); + bottom.set_steer_enabled(true); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); let codex_home = cfg.codex_home.clone(); let widget = ChatWidget { @@ -378,6 +379,7 @@ async fn make_chatwidget_manual( codex_op_tx: op_tx, bottom_pane: bottom, active_cell: None, + active_cell_revision: 0, config: cfg, model: resolved_model.clone(), auth_manager: auth_manager.clone(), @@ -1008,7 +1010,7 @@ async fn enqueueing_history_prompt_multiple_times_is_stable() { assert_eq!(chat.bottom_pane.composer_text(), "repeat me"); // Queue the prompt while the task is running. - chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); } assert_eq!(chat.queued_user_messages.len(), 3); @@ -1030,7 +1032,7 @@ async fn streaming_final_answer_keeps_task_running_state() { chat.bottom_pane .set_composer_text("queued submission".to_string()); - chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); assert_eq!(chat.queued_user_messages.len(), 1); assert_eq!( diff --git a/codex-rs/tui2/src/cli.rs b/codex-rs/tui2/src/cli.rs index 7faeac4e1b..69014d45db 100644 --- a/codex-rs/tui2/src/cli.rs +++ b/codex-rs/tui2/src/cli.rs @@ -58,7 +58,7 @@ pub struct Cli { #[arg(long = "oss", default_value_t = false)] pub oss: bool, - /// Specify which local provider to use (lmstudio or ollama). + /// Specify which local provider to use (lmstudio, ollama, or ollama-chat). /// If not specified with --oss, will use config default or show selection. #[arg(long = "local-provider")] pub oss_provider: Option, diff --git a/codex-rs/tui2/src/history_cell.rs b/codex-rs/tui2/src/history_cell.rs index 3124d0fc31..f8a15c50e4 100644 --- a/codex-rs/tui2/src/history_cell.rs +++ b/codex-rs/tui2/src/history_cell.rs @@ -1,3 +1,15 @@ +//! Transcript/history cells for the Codex TUI. +//! +//! A `HistoryCell` is the unit of display in the conversation UI, representing both committed +//! transcript entries and, transiently, an in-flight active cell that can mutate in place while +//! streaming. +//! +//! The transcript overlay (`Ctrl+T`) appends a cached live tail derived from the active cell, and +//! that cached tail is refreshed based on an active-cell cache key. Cells that change based on +//! elapsed time expose `transcript_animation_tick()`, and code that mutates the active cell in place +//! bumps the active-cell revision tracked by `ChatWidget`, so the cache key changes whenever the +//! rendered transcript output can change. + use crate::diff_render::create_diff_summary; use crate::diff_render::display_path_for; use crate::exec_cell::CommandOutput; @@ -58,33 +70,26 @@ use unicode_width::UnicodeWidthStr; /// Visual transcript lines plus soft-wrap joiners. /// /// A history cell can produce multiple "visual lines" once prefixes/indents and wrapping are -/// applied. Clipboard reconstruction needs more information than just those lines: users expect -/// soft-wrapped prose to copy as a single logical line, while explicit newlines and spacer rows -/// should remain hard breaks. +/// applied. Clipboard reconstruction needs more information than just those lines because users +/// expect soft-wrapped prose to copy as a single logical line, while explicit newlines and spacer +/// rows should remain hard breaks. /// /// `joiner_before` records, for each output line, whether it is a continuation created by the /// wrapping algorithm and what string should be inserted at the wrap boundary when joining lines. /// This avoids heuristics like always inserting a space, and instead preserves the exact whitespace /// that was skipped at the boundary. /// -/// ## Note for `codex-tui` vs `codex-tui2` +/// In `codex-tui`, `HistoryCell` only exposes `transcript_lines(...)` and the UI generally does not +/// need to reconstruct clipboard text across off-screen history or soft-wrap boundaries. In +/// `codex-tui2`, transcript selection and copy are app-driven (not terminal-driven) and may span +/// content that is not currently visible, so we need extra metadata to distinguish hard breaks from +/// soft wraps and to preserve the exact whitespace at wrap boundaries. /// -/// In `codex-tui`, `HistoryCell` only exposes `transcript_lines(...)` and the UI generally doesn't -/// need to reconstruct clipboard text across off-screen history or soft-wrap boundaries. -/// -/// In `codex-tui2`, transcript selection and copy are app-driven (not terminal-driven) and may span -/// content that isn't currently visible. That means we need additional metadata to distinguish hard -/// breaks from soft wraps and to preserve the exact whitespace at wrap boundaries. -/// -/// Invariants: -/// - `joiner_before.len() == lines.len()` -/// - `joiner_before[0]` is always `None` -/// - `None` represents a hard break -/// - `Some(joiner)` represents a soft wrap continuation -/// -/// Consumers: -/// - `transcript_render` threads joiners through transcript flattening/wrapping. -/// - `transcript_copy` uses them to join wrapped prose while preserving hard breaks. +/// The invariant is that `joiner_before.len() == lines.len()` and `joiner_before[0]` is always +/// `None`. A `None` entry represents a hard break (copy inserts a newline), while `Some(joiner)` +/// represents a soft wrap continuation (copy inserts `joiner` and continues on the same logical +/// line). This data is produced by transcript rendering and consumed by transcript copy to keep +/// clipboard output faithful to what the user saw. #[derive(Debug, Clone)] pub(crate) struct TranscriptLinesWithJoiners { /// Visual transcript lines for a history cell, including any indent/prefix spans. @@ -162,6 +167,20 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any { fn is_stream_continuation(&self) -> bool { false } + + /// Returns a coarse "animation tick" when transcript output is time-dependent. + /// + /// The transcript overlay caches the rendered output of the in-flight active cell, so cells + /// that include time-based UI (spinner, shimmer, etc.) should return a tick that changes over + /// time to signal that the cached tail should be recomputed. Returning `None` means the + /// transcript lines are stable, while returning `Some(tick)` during an in-flight animation + /// allows the overlay to keep up with the main viewport. + /// + /// If a cell uses time-based visuals but always returns `None`, `Ctrl+T` can appear "frozen" on + /// the first rendered frame even though the main viewport is animating. + fn transcript_animation_tick(&self) -> Option { + None + } } impl Renderable for Box { @@ -1253,6 +1272,13 @@ impl HistoryCell for McpToolCallCell { lines } + + fn transcript_animation_tick(&self) -> Option { + if !self.animations_enabled || self.result.is_some() { + return None; + } + Some((self.start_time.elapsed().as_millis() / 50) as u64) + } } pub(crate) fn new_active_mcp_tool_call( @@ -1936,7 +1962,8 @@ mod tests { enabled_tools: None, disabled_tools: None, }; - config.mcp_servers.insert("docs".to_string(), stdio_config); + let mut servers = config.mcp_servers.get().clone(); + servers.insert("docs".to_string(), stdio_config); let mut headers = HashMap::new(); headers.insert("Authorization".to_string(), "Bearer secret".to_string()); @@ -1955,7 +1982,11 @@ mod tests { enabled_tools: None, disabled_tools: None, }; - config.mcp_servers.insert("http".to_string(), http_config); + servers.insert("http".to_string(), http_config); + config + .mcp_servers + .set(servers) + .expect("test mcp servers should accept any configuration"); let mut tools: HashMap = HashMap::new(); tools.insert( diff --git a/codex-rs/tui2/src/lib.rs b/codex-rs/tui2/src/lib.rs index aad676fd4e..e4de7a3cb7 100644 --- a/codex-rs/tui2/src/lib.rs +++ b/codex-rs/tui2/src/lib.rs @@ -9,6 +9,7 @@ pub use app::AppExitInfo; use codex_app_server_protocol::AuthMode; use codex_common::oss::ensure_oss_provider_ready; use codex_common::oss::get_default_model_for_oss_provider; +use codex_common::oss::ollama_chat_deprecation_notice; use codex_core::AuthManager; use codex_core::CodexAuth; use codex_core::INTERACTIVE_SESSION_SOURCES; @@ -451,6 +452,13 @@ async fn run_ratatui_app( initial_config }; + let ollama_chat_support_notice = match ollama_chat_deprecation_notice(&config).await { + Ok(notice) => notice, + Err(err) => { + tracing::warn!(?err, "Failed to detect Ollama wire API"); + None + } + }; let mut missing_session_exit = |id_str: &str, action: &str| { error!("Error finding conversation path: {id_str}"); restore(); @@ -614,6 +622,7 @@ async fn run_ratatui_app( session_selection, feedback, should_show_trust_screen, // Proxy to: is it a first run in this directory? + ollama_chat_support_notice, ) .await; diff --git a/codex-rs/tui2/src/model_migration.rs b/codex-rs/tui2/src/model_migration.rs index 0b74ad40a5..b1886d7a62 100644 --- a/codex-rs/tui2/src/model_migration.rs +++ b/codex-rs/tui2/src/model_migration.rs @@ -1,4 +1,5 @@ use crate::key_hint; +use crate::markdown_render::render_markdown_text_with_width; use crate::render::Insets; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; @@ -34,6 +35,7 @@ pub(crate) struct ModelMigrationCopy { pub heading: Vec>, pub content: Vec>, pub can_opt_out: bool, + pub markdown: Option, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -55,13 +57,28 @@ impl MigrationMenuOption { } } +#[allow(clippy::too_many_arguments)] pub(crate) fn migration_copy_for_models( current_model: &str, target_model: &str, target_display_name: String, target_description: Option, + migration_markdown: Option, can_opt_out: bool, ) -> ModelMigrationCopy { + if let Some(migration_markdown) = migration_markdown { + return ModelMigrationCopy { + heading: Vec::new(), + content: Vec::new(), + can_opt_out, + markdown: Some(fill_migration_markdown( + &migration_markdown, + current_model, + target_model, + )), + }; + } + let heading_text = Span::from(format!("Try {target_display_name}")).bold(); let description_line = target_description .filter(|desc| !desc.is_empty()) @@ -93,6 +110,7 @@ pub(crate) fn migration_copy_for_models( heading: vec![heading_text], content, can_opt_out, + markdown: None, } } @@ -218,9 +236,13 @@ impl WidgetRef for &ModelMigrationScreen { let mut column = ColumnRenderable::new(); column.push(""); - column.push(self.heading_line()); - column.push(Line::from("")); - self.render_content(&mut column); + if let Some(markdown) = self.copy.markdown.as_ref() { + self.render_markdown_content(markdown, area.width, &mut column); + } else { + column.push(self.heading_line()); + column.push(Line::from("")); + self.render_content(&mut column); + } if self.copy.can_opt_out { self.render_menu(&mut column); } @@ -271,6 +293,21 @@ impl ModelMigrationScreen { } } + fn render_markdown_content( + &self, + markdown: &str, + area_width: u16, + column: &mut ColumnRenderable, + ) { + let horizontal_inset = 2; + let content_width = area_width.saturating_sub(horizontal_inset); + let wrap_width = (content_width > 0).then_some(content_width as usize); + let rendered = render_markdown_text_with_width(markdown, wrap_width); + for line in rendered.lines { + column.push(line.inset(Insets::tlbr(0, horizontal_inset, 0, 0))); + } + } + fn render_menu(&self, column: &mut ColumnRenderable) { column.push(Line::from("")); column.push( @@ -329,6 +366,12 @@ fn is_ctrl_exit_combo(key_event: KeyEvent) -> bool { && matches!(key_event.code, KeyCode::Char('c') | KeyCode::Char('d')) } +fn fill_migration_markdown(template: &str, current_model: &str, target_model: &str) -> String { + template + .replace("{model_from}", current_model) + .replace("{model_to}", target_model) +} + #[cfg(test)] mod tests { use super::ModelMigrationScreen; @@ -356,6 +399,7 @@ mod tests { "gpt-5.1-codex-max", "gpt-5.1-codex-max".to_string(), Some("Codex-optimized flagship for deep and fast reasoning.".to_string()), + None, true, ), ); @@ -382,6 +426,7 @@ mod tests { "gpt-5.1", "gpt-5.1".to_string(), Some("Broad world knowledge with strong general reasoning.".to_string()), + None, false, ), ); @@ -406,6 +451,7 @@ mod tests { "gpt-5.1-codex-max", "gpt-5.1-codex-max".to_string(), Some("Codex-optimized flagship for deep and fast reasoning.".to_string()), + None, false, ), ); @@ -430,6 +476,7 @@ mod tests { "gpt-5.1-codex-mini", "gpt-5.1-codex-mini".to_string(), Some("Optimized for codex. Cheaper, faster, but less capable.".to_string()), + None, false, ), ); @@ -450,6 +497,7 @@ mod tests { "gpt-new", "gpt-new".to_string(), Some("Latest recommended model for better performance.".to_string()), + None, true, ), ); @@ -476,6 +524,7 @@ mod tests { "gpt-new", "gpt-new".to_string(), Some("Latest recommended model for better performance.".to_string()), + None, true, ), ); diff --git a/codex-rs/tui2/src/oss_selection.rs b/codex-rs/tui2/src/oss_selection.rs index eb1ca18231..7267919109 100644 --- a/codex-rs/tui2/src/oss_selection.rs +++ b/codex-rs/tui2/src/oss_selection.rs @@ -4,6 +4,7 @@ use std::sync::LazyLock; use codex_core::DEFAULT_LMSTUDIO_PORT; use codex_core::DEFAULT_OLLAMA_PORT; use codex_core::LMSTUDIO_OSS_PROVIDER_ID; +use codex_core::OLLAMA_CHAT_PROVIDER_ID; use codex_core::OLLAMA_OSS_PROVIDER_ID; use codex_core::config::set_default_oss_provider; use crossterm::event::Event; @@ -70,10 +71,16 @@ static OSS_SELECT_OPTIONS: LazyLock> = LazyLock::new(|| { }, SelectOption { label: Line::from(vec!["O".underlined(), "llama".into()]), - description: "Local Ollama server (default port 11434)", + description: "Local Ollama server (Responses API, default port 11434)", key: KeyCode::Char('o'), provider_id: OLLAMA_OSS_PROVIDER_ID, }, + SelectOption { + label: Line::from(vec!["Ollama (".into(), "c".underlined(), "hat)".into()]), + description: "Local Ollama server (chat wire API, default port 11434)", + key: KeyCode::Char('c'), + provider_id: OLLAMA_CHAT_PROVIDER_ID, + }, ] }); @@ -99,7 +106,11 @@ impl OssSelectionWidget<'_> { status: lmstudio_status, }, ProviderOption { - name: "Ollama".to_string(), + name: "Ollama (Responses)".to_string(), + status: ollama_status.clone(), + }, + ProviderOption { + name: "Ollama (Chat)".to_string(), status: ollama_status, }, ]; diff --git a/codex-rs/tui2/src/pager_overlay.rs b/codex-rs/tui2/src/pager_overlay.rs index 3d24aef96a..6fb6d123cb 100644 --- a/codex-rs/tui2/src/pager_overlay.rs +++ b/codex-rs/tui2/src/pager_overlay.rs @@ -1,6 +1,24 @@ +//! Overlay UIs rendered in an alternate screen. +//! +//! This module implements the pager-style overlays used by the TUI, including the transcript +//! overlay (`Ctrl+T`) that renders a full history view separate from the main viewport. +//! +//! The transcript overlay renders committed transcript cells plus an optional render-only live tail +//! derived from the current in-flight active cell. Because rebuilding wrapped `Line`s on every draw +//! can be expensive, that live tail is cached and only recomputed when its cache key changes, which +//! is derived from the terminal width (wrapping), an active-cell revision (in-place mutations), the +//! stream-continuation flag (spacing), and an animation tick (time-based spinner/shimmer output). +//! +//! The transcript overlay live tail is kept in sync by `App` during draws: `App` supplies an +//! `ActiveCellTranscriptKey` and a function to compute the active cell transcript lines, and +//! `TranscriptOverlay::sync_live_tail` uses the key to decide when the cached tail must be +//! recomputed. `ChatWidget` is responsible for producing a key that changes when the active cell +//! mutates in place or when its transcript output is time-dependent. + use std::io::Result; use std::sync::Arc; +use crate::chatwidget::ActiveCellTranscriptKey; use crate::history_cell::HistoryCell; use crate::history_cell::UserHistoryCell; use crate::key_hint; @@ -420,13 +438,39 @@ impl Renderable for CellRenderable { } pub(crate) struct TranscriptOverlay { + /// Pager UI state and the renderables currently displayed. + /// + /// The invariant is that `view.renderables` is `render_cells(cells)` plus an optional trailing + /// live-tail renderable appended after the committed cells. view: PagerView, + /// Committed transcript cells (does not include the live tail). cells: Vec>, highlight_cell: Option, + /// Cache key for the render-only live tail appended after committed cells. + live_tail_key: Option, is_done: bool, } +/// Cache key for the active-cell "live tail" appended to the transcript overlay. +/// +/// Changing any field implies a different rendered tail. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct LiveTailKey { + /// Current terminal width, which affects wrapping. + width: u16, + /// Revision that changes on in-place active cell transcript updates. + revision: u64, + /// Whether the tail should be treated as a continuation for spacing. + is_stream_continuation: bool, + /// Optional animation tick to refresh spinners/progress indicators. + animation_tick: Option, +} + impl TranscriptOverlay { + /// Creates a transcript overlay for a fixed set of committed cells. + /// + /// 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 { Self { view: PagerView::new( @@ -436,6 +480,7 @@ impl TranscriptOverlay { ), cells: transcript_cells, highlight_cell: None, + live_tail_key: None, is_done: false, } } @@ -476,10 +521,85 @@ impl TranscriptOverlay { .collect() } + /// Insert a committed history cell while keeping any cached live tail. + /// + /// The live tail is temporarily removed, the committed cells are rebuilt, + /// then the tail is reattached. If the tail previously had no leading + /// spacing because it was the only renderable, we add the missing inset + /// when the first committed cell arrives. + /// + /// This expects `cell` to be a committed transcript cell (not the in-flight active cell). If + /// the overlay was scrolled to bottom before insertion, it remains pinned to bottom after the + /// insertion to preserve the "follow along" behavior. pub(crate) fn insert_cell(&mut self, cell: Arc) { let follow_bottom = self.view.is_scrolled_to_bottom(); + let had_prior_cells = !self.cells.is_empty(); + let tail_renderable = self.take_live_tail_renderable(); self.cells.push(cell); self.view.renderables = Self::render_cells(&self.cells, self.highlight_cell); + if let Some(tail) = tail_renderable { + let tail = if !had_prior_cells + && self + .live_tail_key + .is_some_and(|key| !key.is_stream_continuation) + { + // The tail was rendered as the only entry, so it lacks a top + // inset; add one now that it follows a committed cell. + Box::new(InsetRenderable::new(tail, Insets::tlbr(1, 0, 0, 0))) + as Box + } else { + tail + }; + self.view.renderables.push(tail); + } + if follow_bottom { + self.view.scroll_offset = usize::MAX; + } + } + + /// Sync the active-cell live tail with the current width and cell state. + /// + /// Recomputes the tail only when the cache key changes, preserving scroll + /// position and dropping the tail if there is nothing to render. + /// + /// The overlay owns committed transcript cells while the live tail is derived from the current + /// active cell, which can mutate in place while streaming. `App` calls this during + /// `TuiEvent::Draw` for `Overlay::Transcript`, passing a key that changes when the active cell + /// mutates or animates so the cached tail stays fresh. + /// + /// Passing a key that does not change on in-place active-cell mutations will freeze the tail in + /// `Ctrl+T` while the main viewport continues to update. + pub(crate) fn sync_live_tail( + &mut self, + width: u16, + active_key: Option, + compute_lines: impl FnOnce(u16) -> Option>>, + ) { + let next_key = active_key.map(|key| LiveTailKey { + width, + revision: key.revision, + is_stream_continuation: key.is_stream_continuation, + animation_tick: key.animation_tick, + }); + + if self.live_tail_key == next_key { + return; + } + let follow_bottom = self.view.is_scrolled_to_bottom(); + + self.take_live_tail_renderable(); + self.live_tail_key = next_key; + + if let Some(key) = next_key { + let lines = compute_lines(width).unwrap_or_default(); + if !lines.is_empty() { + self.view.renderables.push(Self::live_tail_renderable( + lines, + !self.cells.is_empty(), + key.is_stream_continuation, + )); + } + } if follow_bottom { self.view.scroll_offset = usize::MAX; } @@ -487,12 +607,50 @@ impl TranscriptOverlay { pub(crate) fn set_highlight_cell(&mut self, cell: Option) { self.highlight_cell = cell; - self.view.renderables = Self::render_cells(&self.cells, self.highlight_cell); + self.rebuild_renderables(); if let Some(idx) = self.highlight_cell { self.view.scroll_chunk_into_view(idx); } } + /// Returns whether the underlying pager view is currently pinned to the bottom. + /// + /// This is used by the `App` draw loop to decide whether to schedule animation frames for the + /// live tail (if the user has scrolled up, we avoid driving animation). + pub(crate) fn is_scrolled_to_bottom(&self) -> bool { + self.view.is_scrolled_to_bottom() + } + + fn rebuild_renderables(&mut self) { + let tail_renderable = self.take_live_tail_renderable(); + self.view.renderables = Self::render_cells(&self.cells, self.highlight_cell); + if let Some(tail) = tail_renderable { + self.view.renderables.push(tail); + } + } + + /// Removes and returns the cached live-tail renderable, if present. + /// + /// The live tail is represented as a single optional renderable appended after the committed + /// cell renderables, so this relies on the live tail always being the final entry in + /// `view.renderables` when present. + fn take_live_tail_renderable(&mut self) -> Option> { + (self.view.renderables.len() > self.cells.len()).then(|| self.view.renderables.pop())? + } + + fn live_tail_renderable( + lines: Vec>, + has_prior_cells: bool, + is_stream_continuation: bool, + ) -> Box { + let paragraph = Paragraph::new(Text::from(lines)); + let mut renderable: Box = Box::new(CachedRenderable::new(paragraph)); + if has_prior_cells && !is_stream_continuation { + renderable = Box::new(InsetRenderable::new(renderable, Insets::tlbr(1, 0, 0, 0))); + } + renderable + } + 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); @@ -633,6 +791,7 @@ mod tests { use codex_core::protocol::ExecCommandSource; use codex_core::protocol::ReviewDecision; use insta::assert_snapshot; + use pretty_assertions::assert_eq; use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; @@ -717,6 +876,52 @@ mod tests { assert_snapshot!(term.backend()); } + #[test] + fn transcript_overlay_renders_live_tail() { + let mut overlay = TranscriptOverlay::new(vec![Arc::new(TestCell { + lines: vec![Line::from("alpha")], + })]); + overlay.sync_live_tail( + 40, + Some(ActiveCellTranscriptKey { + revision: 1, + is_stream_continuation: false, + animation_tick: None, + }), + |_| Some(vec![Line::from("tail")]), + ); + + let mut term = Terminal::new(TestBackend::new(40, 10)).expect("term"); + term.draw(|f| overlay.render(f.area(), f.buffer_mut())) + .expect("draw"); + assert_snapshot!(term.backend()); + } + + #[test] + fn transcript_overlay_sync_live_tail_is_noop_for_identical_key() { + let mut overlay = TranscriptOverlay::new(vec![Arc::new(TestCell { + lines: vec![Line::from("alpha")], + })]); + + let calls = std::cell::Cell::new(0usize); + let key = ActiveCellTranscriptKey { + revision: 1, + is_stream_continuation: false, + animation_tick: None, + }; + + overlay.sync_live_tail(40, Some(key), |_| { + calls.set(calls.get() + 1); + Some(vec![Line::from("tail")]) + }); + overlay.sync_live_tail(40, Some(key), |_| { + calls.set(calls.get() + 1); + Some(vec![Line::from("tail2")]) + }); + + assert_eq!(calls.get(), 1); + } + fn buffer_to_text(buf: &Buffer, area: Rect) -> String { let mut out = String::new(); for y in area.y..area.bottom() { diff --git a/codex-rs/tui2/src/snapshots/codex_tui2__app__tests__model_migration_prompt_shows_for_hidden_model.snap b/codex-rs/tui2/src/snapshots/codex_tui2__app__tests__model_migration_prompt_shows_for_hidden_model.snap index acfdedc100..d182855041 100644 --- a/codex-rs/tui2/src/snapshots/codex_tui2__app__tests__model_migration_prompt_shows_for_hidden_model.snap +++ b/codex-rs/tui2/src/snapshots/codex_tui2__app__tests__model_migration_prompt_shows_for_hidden_model.snap @@ -1,12 +1,9 @@ --- source: tui2/src/app.rs -assertion_line: 2314 expression: model_migration_copy_to_plain_text(©) --- -Try gpt-5.2-codex +**Codex just got an upgrade. Introducing gpt-5.2-codex.** -We recommend switching from gpt-5.1-codex to gpt-5.2-codex. - -Latest frontier agentic coding model. +Codex is now powered by gpt-5.2-codex, our latest frontier agentic coding model. It is smarter and faster than its predecessors and capable of long-running project-scale work. Learn more about gpt-5.2-codex at https://openai.com/index/introducing-gpt-5-2-codex You can continue using gpt-5.1-codex if you prefer. diff --git a/codex-rs/tui2/src/snapshots/codex_tui2__pager_overlay__tests__transcript_overlay_renders_live_tail.snap b/codex-rs/tui2/src/snapshots/codex_tui2__pager_overlay__tests__transcript_overlay_renders_live_tail.snap new file mode 100644 index 0000000000..5911dd96bc --- /dev/null +++ b/codex-rs/tui2/src/snapshots/codex_tui2__pager_overlay__tests__transcript_overlay_renders_live_tail.snap @@ -0,0 +1,14 @@ +--- +source: tui2/src/pager_overlay.rs +expression: term.backend() +--- +"/ T R A N S C R I P T / / / / / / / / / " +"alpha " +" " +"tail " +"~ " +"~ " +"───────────────────────────────── 100% ─" +" ↑/↓ to scroll pgup/pgdn to page hom" +" q to quit esc to edit prev " +" " diff --git a/codex-rs/tui2/tooltips.txt b/codex-rs/tui2/tooltips.txt index 33e891611b..0fdb0ca73b 100644 --- a/codex-rs/tui2/tooltips.txt +++ b/codex-rs/tui2/tooltips.txt @@ -6,5 +6,6 @@ You can run any shell command from Codex using `!` (e.g. `!ls`) Type / to open the command popup; Tab autocompletes slash commands and saved prompts. You can define your own `/` commands with custom prompts. More info: https://developers.openai.com/codex/guides/slash-commands#create-your-own-slash-commands-with-custom-prompts When the composer is empty, press Esc to step back and edit your last message; Enter confirms. +Press Tab to queue a message instead of sending it immediately; Enter always sends immediately. Paste an image with Ctrl+V to attach it to your next message. You can resume a previous conversation by running `codex resume` diff --git a/docs/config.md b/docs/config.md index 2b64253d30..0b5fecf6e7 100644 --- a/docs/config.md +++ b/docs/config.md @@ -17,3 +17,7 @@ Codex can connect to MCP servers configured in `~/.codex/config.toml`. See the c Codex can run a notification hook when the agent finishes a turn. See the configuration reference for the latest notification settings: - https://developers.openai.com/codex/config-reference + +## JSON Schema + +The generated JSON Schema for `config.toml` lives at `codex-rs/core/config.schema.json`. diff --git a/docs/tui-chat-composer.md b/docs/tui-chat-composer.md new file mode 100644 index 0000000000..cc3beb17fe --- /dev/null +++ b/docs/tui-chat-composer.md @@ -0,0 +1,205 @@ +# Chat Composer state machine (TUI) + +This note documents the `ChatComposer` input state machine and the paste-related behavior added +for Windows terminals. + +Primary implementations: + +- `codex-rs/tui/src/bottom_pane/chat_composer.rs` +- `codex-rs/tui2/src/bottom_pane/chat_composer.rs` + +Paste-burst detector: + +- `codex-rs/tui/src/bottom_pane/paste_burst.rs` +- `codex-rs/tui2/src/bottom_pane/paste_burst.rs` + +## What problem is being solved? + +On some terminals (notably on Windows via `crossterm`), _bracketed paste_ is not reliably surfaced +as a single paste event. Instead, pasting multi-line content can show up as a rapid sequence of +key events: + +- `KeyCode::Char(..)` for text +- `KeyCode::Enter` for newlines + +If the composer treats those events as “normal typing”, it can: + +- accidentally trigger UI toggles (e.g. `?`) while the paste is still streaming, +- submit the message mid-paste when an `Enter` arrives, +- render a typed prefix, then “reclassify” it as paste once enough chars arrive (flicker). + +The solution is to detect paste-like _bursts_ and buffer them into a single explicit +`handle_paste(String)` call. + +## High-level state machines + +`ChatComposer` effectively combines two small state machines: + +1. **UI mode**: which popup (if any) is active. + - `ActivePopup::None | Command | File | Skill` +2. **Paste burst**: transient detection state for non-bracketed paste. + - implemented by `PasteBurst` + +### Key event routing + +`ChatComposer::handle_key_event` dispatches based on `active_popup`: + +- If a popup is visible, a popup-specific handler processes the key first (navigation, selection, + completion). +- Otherwise, `handle_key_event_without_popup` handles higher-level semantics (Enter submit, + history navigation, etc). +- After handling the key, `sync_popups()` runs so popup visibility/filters stay consistent with the + latest text + cursor. + +## Paste burst: concepts and assumptions + +The burst detector is intentionally conservative: it only processes “plain” character input +(no Ctrl/Alt modifiers). Everything else flushes and/or clears the burst window so shortcuts keep +their normal meaning. + +### Conceptual `PasteBurst` states + +- **Idle**: no buffer, no pending char. +- **Pending first char** (ASCII only): hold one fast character very briefly to avoid rendering it + and then immediately removing it if the stream turns out to be a paste. +- **Active buffer**: once a burst is classified as paste-like, accumulate the content into a + `String` buffer. +- **Enter suppression window**: keep treating `Enter` as “newline” briefly after burst activity so + multiline pastes remain grouped even if there are tiny gaps. + +### ASCII vs non-ASCII (IME) input + +Non-ASCII characters frequently come from IMEs and can legitimately arrive in quick bursts. Holding +the first character in that case can feel like dropped input. + +The composer therefore distinguishes: + +- **ASCII path**: allow holding the first fast char (`PasteBurst::on_plain_char`). +- **non-ASCII path**: never hold the first char (`PasteBurst::on_plain_char_no_hold`), but still + allow burst detection. When a burst is detected on this path, the already-inserted prefix may be + retroactively removed from the textarea and moved into the paste buffer. + +To avoid misclassifying IME bursts as paste, the non-ASCII retro-capture path runs an additional +heuristic (`PasteBurst::decide_begin_buffer`) to determine whether the retro-grabbed prefix “looks +pastey” (e.g. contains whitespace or is long). + +### Disabling burst detection + +`ChatComposer` supports `disable_paste_burst` as an escape hatch. + +When enabled: + +- The burst detector is bypassed for new input (no flicker suppression hold and no burst buffering + decisions for incoming characters). +- The key stream is treated as normal typing (including normal slash command behavior). +- Enabling the flag clears the burst classification window. In the current implementation it does + **not** flush or clear an already-buffered burst, so callers should avoid toggling this flag + mid-burst (or should flush first). + +### Enter handling + +When paste-burst buffering is active, Enter is treated as “append `\n` to the burst” rather than +“submit the message”. This prevents mid-paste submission for multiline pastes that are emitted as +`Enter` key events. + +The composer also disables burst-based Enter suppression inside slash-command context (popup open +or the first line begins with `/`) so command dispatch is predictable. + +## PasteBurst: event-level behavior (cheat sheet) + +This section spells out how `ChatComposer` interprets the `PasteBurst` decisions. It’s intended to +make the state transitions reviewable without having to “run the code in your head”. + +### Plain ASCII `KeyCode::Char(c)` (no Ctrl/Alt modifiers) + +`ChatComposer::handle_input_basic` calls `PasteBurst::on_plain_char(c, now)` and switches on the +returned `CharDecision`: + +- `RetainFirstChar`: do **not** insert `c` into the textarea yet. A UI tick later may flush it as a + normal typed char via `PasteBurst::flush_if_due`. +- `BeginBufferFromPending`: the first ASCII char is already held/buffered; append `c` via + `PasteBurst::append_char_to_buffer`. +- `BeginBuffer { retro_chars }`: attempt a retro-capture of the already-inserted prefix: + - call `PasteBurst::decide_begin_buffer(now, before_cursor, retro_chars)`; + - if it returns `Some(grab)`, delete `grab.start_byte..cursor` from the textarea and then append + `c` to the buffer; + - if it returns `None`, fall back to normal insertion. +- `BufferAppend`: append `c` to the active buffer. + +### Plain non-ASCII `KeyCode::Char(c)` (no Ctrl/Alt modifiers) + +`ChatComposer::handle_non_ascii_char` uses a slightly different flow: + +- It first flushes any pending transient ASCII state with `PasteBurst::flush_before_modified_input` + (which includes a single held ASCII char). +- If a burst is already active, `PasteBurst::try_append_char_if_active(c, now)` appends `c` directly. +- Otherwise it calls `PasteBurst::on_plain_char_no_hold(now)`: + - `BufferAppend`: append `c` to the active buffer. + - `BeginBuffer { retro_chars }`: run `decide_begin_buffer(..)` and, if it starts buffering, delete + the retro-grabbed prefix from the textarea and append `c`. + - `None`: insert `c` into the textarea normally. + +The extra `decide_begin_buffer` heuristic on this path is intentional: IME input can arrive as +quick bursts, so the code only retro-grabs if the prefix “looks pastey” (whitespace, or a long +enough run) to avoid misclassifying IME composition as paste. + +### `KeyCode::Enter`: newline vs submit + +There are two distinct “Enter becomes newline” mechanisms: + +- **While in a burst context** (`paste_burst.is_active()`): `append_newline_if_active(now)` appends + `\n` into the burst buffer so multi-line pastes stay buffered as one explicit paste. +- **Immediately after burst activity** (enter suppression window): + `newline_should_insert_instead_of_submit(now)` inserts `\n` into the textarea and calls + `extend_window(now)` so a slightly-late Enter keeps behaving like “newline” rather than “submit”. + +Both are disabled inside slash-command context (command popup is active or the first line begins +with `/`) so Enter keeps its normal “submit/execute” semantics while composing commands. + +### Non-char keys / Ctrl+modified input + +Non-char input must not leak burst state across unrelated actions: + +- If there is buffered burst text, callers should flush it before calling + `clear_window_after_non_char` (see “Pitfalls worth calling out”), typically via + `PasteBurst::flush_before_modified_input`. +- `PasteBurst::clear_window_after_non_char` clears the “recent burst” window so the next keystroke + doesn’t get incorrectly grouped into a previous paste. + +### Pitfalls worth calling out + +- `PasteBurst::clear_window_after_non_char` clears `last_plain_char_time`. If you call it while + `buffer` is non-empty and _haven’t already flushed_, `flush_if_due()` no longer has a timestamp + to time out against, so the buffered text may never flush. Treat `clear_window_after_non_char` as + “drop classification context after flush”, not “flush”. +- `PasteBurst::flush_if_due` uses a strict `>` comparison, so tests and UI ticks should cross the + threshold by at least 1ms (see `PasteBurst::recommended_flush_delay`). + +## Notable interactions / invariants + +- The composer frequently slices `textarea.text()` using the cursor position; all code that + slices must clamp the cursor to a UTF-8 char boundary first. +- `sync_popups()` must run after any change that can affect popup visibility or filtering: + inserting, deleting, flushing a burst, applying a paste placeholder, etc. +- Shortcut overlay toggling via `?` is gated on `!is_in_paste_burst()` so pastes cannot flip UI + modes while streaming. + +## Tests that pin behavior + +The `PasteBurst` logic is currently exercised through `ChatComposer` integration tests. + +- `codex-rs/tui/src/bottom_pane/chat_composer.rs` + - `non_ascii_burst_handles_newline` + - `ascii_burst_treats_enter_as_newline` + - `question_mark_does_not_toggle_during_paste_burst` + - `burst_paste_fast_small_buffers_and_flushes_on_stop` + - `burst_paste_fast_large_inserts_placeholder_on_flush` +- `codex-rs/tui2/src/bottom_pane/chat_composer.rs` + - `non_ascii_burst_handles_newline` + - `ascii_burst_treats_enter_as_newline` + - `question_mark_does_not_toggle_during_paste_burst` + - `burst_paste_fast_small_buffers_and_flushes_on_stop` + - `burst_paste_fast_large_inserts_placeholder_on_flush` + +This document calls out some additional contracts (like “flush before clearing”) that are not yet +fully pinned by dedicated `PasteBurst` unit tests. diff --git a/justfile b/justfile index bbe18987a8..64f470ca7a 100644 --- a/justfile +++ b/justfile @@ -56,3 +56,7 @@ build-for-release: # Run the MCP server mcp-server-run *args: cargo run -p codex-mcp-server -- "$@" + +# Regenerate the json schema for config.toml from the current config types. +write-config-schema: + cargo run -p codex-core --bin codex-write-config-schema