diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 49c2e6c771..22a1a8320e 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -59,7 +59,7 @@ jobs: working-directory: codex-rs steps: - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@1.90 + - uses: dtolnay/rust-toolchain@1.92 with: components: rustfmt - name: cargo fmt @@ -77,7 +77,7 @@ jobs: working-directory: codex-rs steps: - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@1.90 + - uses: dtolnay/rust-toolchain@1.92 - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2 with: tool: cargo-shear @@ -177,7 +177,7 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@1.90 + - uses: dtolnay/rust-toolchain@1.92 with: targets: ${{ matrix.target }} components: clippy @@ -416,7 +416,7 @@ jobs: - name: Install DotSlash uses: facebook/install-dotslash@v2 - - uses: dtolnay/rust-toolchain@1.90 + - uses: dtolnay/rust-toolchain@1.92 with: targets: ${{ matrix.target }} diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 76f49eeabb..acc8b6bafe 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -80,7 +80,7 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@1.90 + - uses: dtolnay/rust-toolchain@1.92 with: targets: ${{ matrix.target }} diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml index 3e5a249d40..f506dce6ba 100644 --- a/.github/workflows/sdk.yml +++ b/.github/workflows/sdk.yml @@ -24,7 +24,7 @@ jobs: node-version: 22 cache: pnpm - - uses: dtolnay/rust-toolchain@1.90 + - uses: dtolnay/rust-toolchain@1.92 - name: build codex run: cargo build --bin codex diff --git a/.github/workflows/shell-tool-mcp.yml b/.github/workflows/shell-tool-mcp.yml index b27b4bdbd4..bc49a90fa6 100644 --- a/.github/workflows/shell-tool-mcp.yml +++ b/.github/workflows/shell-tool-mcp.yml @@ -93,7 +93,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@1.90 + - uses: dtolnay/rust-toolchain@1.92 with: targets: ${{ matrix.target }} diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 7a7f9ce498..93afa2c2e2 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -1,5 +1,5 @@ { - "lockFileVersion": 24, + "lockFileVersion": 26, "registryFileHashes": { "https://bcr.bazel.build/bazel_registry.json": "8a28e4aff06ee60aed2a8c281907fb8bcbf3b753c91fb5a5c57da3215d5b3497", "https://bcr.bazel.build/modules/abseil-cpp/20210324.2/MODULE.bazel": "7cd0312e064fde87c8d1cd79ba06c876bd23630c83466e9500321be55c96ace2", @@ -9,11 +9,19 @@ "https://bcr.bazel.build/modules/abseil-cpp/20230802.0/MODULE.bazel": "d253ae36a8bd9ee3c5955384096ccb6baf16a1b1e93e858370da0a3b94f77c16", "https://bcr.bazel.build/modules/abseil-cpp/20230802.1/MODULE.bazel": "fa92e2eb41a04df73cdabeec37107316f7e5272650f81d6cc096418fe647b915", "https://bcr.bazel.build/modules/abseil-cpp/20240116.1/MODULE.bazel": "37bcdb4440fbb61df6a1c296ae01b327f19e9bb521f9b8e26ec854b6f97309ed", - "https://bcr.bazel.build/modules/abseil-cpp/20240116.1/source.json": "9be551b8d4e3ef76875c0d744b5d6a504a27e3ae67bc6b28f46415fd2d2957da", + "https://bcr.bazel.build/modules/abseil-cpp/20240116.2/MODULE.bazel": "73939767a4686cd9a520d16af5ab440071ed75cec1a876bf2fcfaf1f71987a16", + "https://bcr.bazel.build/modules/abseil-cpp/20250127.1/MODULE.bazel": "c4a89e7ceb9bf1e25cf84a9f830ff6b817b72874088bf5141b314726e46a57c1", + "https://bcr.bazel.build/modules/abseil-cpp/20250512.1/MODULE.bazel": "d209fdb6f36ffaf61c509fcc81b19e81b411a999a934a032e10cd009a0226215", + "https://bcr.bazel.build/modules/abseil-cpp/20250814.1/MODULE.bazel": "51f2312901470cdab0dbdf3b88c40cd21c62a7ed58a3de45b365ddc5b11bcab2", + "https://bcr.bazel.build/modules/abseil-cpp/20250814.1/source.json": "cea3901d7e299da7320700abbaafe57a65d039f10d0d7ea601c4a66938ea4b0c", + "https://bcr.bazel.build/modules/apple_support/1.11.1/MODULE.bazel": "1843d7cd8a58369a444fc6000e7304425fba600ff641592161d9f15b179fb896", + "https://bcr.bazel.build/modules/apple_support/1.15.1/MODULE.bazel": "a0556fefca0b1bb2de8567b8827518f94db6a6e7e7d632b4c48dc5f865bc7c85", + "https://bcr.bazel.build/modules/apple_support/1.21.0/MODULE.bazel": "ac1824ed5edf17dee2fdd4927ada30c9f8c3b520be1b5fd02a5da15bc10bff3e", + "https://bcr.bazel.build/modules/apple_support/1.21.1/MODULE.bazel": "5809fa3efab15d1f3c3c635af6974044bac8a4919c62238cce06acee8a8c11f1", "https://bcr.bazel.build/modules/apple_support/1.23.0/MODULE.bazel": "317d47e3f65b580e7fb4221c160797fda48e32f07d2dfff63d754ef2316dcd25", - "https://bcr.bazel.build/modules/apple_support/1.23.1/MODULE.bazel": "53763fed456a968cf919b3240427cf3a9d5481ec5466abc9d5dc51bc70087442", "https://bcr.bazel.build/modules/apple_support/1.24.1/MODULE.bazel": "f46e8ddad60aef170ee92b2f3d00ef66c147ceafea68b6877cb45bd91737f5f8", - "https://bcr.bazel.build/modules/apple_support/1.24.1/source.json": "cf725267cbacc5f028ef13bb77e7f2c2e0066923a4dab1025e4a0511b1ed258a", + "https://bcr.bazel.build/modules/apple_support/1.24.2/MODULE.bazel": "0e62471818affb9f0b26f128831d5c40b074d32e6dda5a0d3852847215a41ca4", + "https://bcr.bazel.build/modules/apple_support/1.24.2/source.json": "2c22c9827093250406c5568da6c54e6fdf0ef06238def3d99c71b12feb057a8d", "https://bcr.bazel.build/modules/aspect_bazel_lib/2.14.0/MODULE.bazel": "2b31ffcc9bdc8295b2167e07a757dbbc9ac8906e7028e5170a3708cecaac119f", "https://bcr.bazel.build/modules/aspect_bazel_lib/2.19.3/MODULE.bazel": "253d739ba126f62a5767d832765b12b59e9f8d2bc88cc1572f4a73e46eb298ca", "https://bcr.bazel.build/modules/aspect_bazel_lib/2.19.3/source.json": "ffab9254c65ba945f8369297ad97ca0dec213d3adc6e07877e23a48624a8b456", @@ -21,16 +29,21 @@ "https://bcr.bazel.build/modules/aspect_tools_telemetry/0.3.2/MODULE.bazel": "598e7fe3b54f5fa64fdbeead1027653963a359cc23561d43680006f3b463d5a4", "https://bcr.bazel.build/modules/aspect_tools_telemetry/0.3.2/source.json": "c6f5c39e6f32eb395f8fdaea63031a233bbe96d49a3bfb9f75f6fce9b74bec6c", "https://bcr.bazel.build/modules/bazel_features/1.1.1/MODULE.bazel": "27b8c79ef57efe08efccbd9dd6ef70d61b4798320b8d3c134fd571f78963dbcd", + "https://bcr.bazel.build/modules/bazel_features/1.10.0/MODULE.bazel": "f75e8807570484a99be90abcd52b5e1f390362c258bcb73106f4544957a48101", "https://bcr.bazel.build/modules/bazel_features/1.11.0/MODULE.bazel": "f9382337dd5a474c3b7d334c2f83e50b6eaedc284253334cf823044a26de03e8", "https://bcr.bazel.build/modules/bazel_features/1.15.0/MODULE.bazel": "d38ff6e517149dc509406aca0db3ad1efdd890a85e049585b7234d04238e2a4d", "https://bcr.bazel.build/modules/bazel_features/1.17.0/MODULE.bazel": "039de32d21b816b47bd42c778e0454217e9c9caac4a3cf8e15c7231ee3ddee4d", "https://bcr.bazel.build/modules/bazel_features/1.18.0/MODULE.bazel": "1be0ae2557ab3a72a57aeb31b29be347bcdc5d2b1eb1e70f39e3851a7e97041a", "https://bcr.bazel.build/modules/bazel_features/1.19.0/MODULE.bazel": "59adcdf28230d220f0067b1f435b8537dd033bfff8db21335ef9217919c7fb58", + "https://bcr.bazel.build/modules/bazel_features/1.21.0/MODULE.bazel": "675642261665d8eea09989aa3b8afb5c37627f1be178382c320d1b46afba5e3b", + "https://bcr.bazel.build/modules/bazel_features/1.23.0/MODULE.bazel": "fd1ac84bc4e97a5a0816b7fd7d4d4f6d837b0047cf4cbd81652d616af3a6591a", "https://bcr.bazel.build/modules/bazel_features/1.24.0/MODULE.bazel": "4796b4c25b47053e9bbffa792b3792d07e228ff66cd0405faef56a978708acd4", "https://bcr.bazel.build/modules/bazel_features/1.27.0/MODULE.bazel": "621eeee06c4458a9121d1f104efb80f39d34deff4984e778359c60eaf1a8cb65", "https://bcr.bazel.build/modules/bazel_features/1.28.0/MODULE.bazel": "4b4200e6cbf8fa335b2c3f43e1d6ef3e240319c33d43d60cc0fbd4b87ece299d", + "https://bcr.bazel.build/modules/bazel_features/1.3.0/MODULE.bazel": "cdcafe83ec318cda34e02948e81d790aab8df7a929cec6f6969f13a489ccecd9", "https://bcr.bazel.build/modules/bazel_features/1.30.0/MODULE.bazel": "a14b62d05969a293b80257e72e597c2da7f717e1e69fa8b339703ed6731bec87", "https://bcr.bazel.build/modules/bazel_features/1.32.0/MODULE.bazel": "095d67022a58cb20f7e20e1aefecfa65257a222c18a938e2914fd257b5f1ccdc", + "https://bcr.bazel.build/modules/bazel_features/1.33.0/MODULE.bazel": "8b8dc9d2a4c88609409c3191165bccec0e4cb044cd7a72ccbe826583303459f6", "https://bcr.bazel.build/modules/bazel_features/1.34.0/MODULE.bazel": "e8475ad7c8965542e0c7aac8af68eb48c4af904be3d614b6aa6274c092c2ea1e", "https://bcr.bazel.build/modules/bazel_features/1.34.0/source.json": "dfa5c4b01110313153b484a735764d247fee5624bbab63d25289e43b151a657a", "https://bcr.bazel.build/modules/bazel_features/1.4.1/MODULE.bazel": "e45b6bb2350aff3e442ae1111c555e27eac1d915e77775f6fdc4b351b758b5d7", @@ -52,20 +65,25 @@ "https://bcr.bazel.build/modules/bazel_skylib/1.8.1/MODULE.bazel": "88ade7293becda963e0e3ea33e7d54d3425127e0a326e0d17da085a5f1f03ff6", "https://bcr.bazel.build/modules/bazel_skylib/1.8.2/MODULE.bazel": "69ad6927098316848b34a9142bcc975e018ba27f08c4ff403f50c1b6e646ca67", "https://bcr.bazel.build/modules/bazel_skylib/1.8.2/source.json": "34a3c8bcf233b835eb74be9d628899bb32999d3e0eadef1947a0a562a2b16ffb", - "https://bcr.bazel.build/modules/buildozer/7.1.2/MODULE.bazel": "2e8dd40ede9c454042645fd8d8d0cd1527966aa5c919de86661e62953cd73d84", - "https://bcr.bazel.build/modules/buildozer/7.1.2/source.json": "c9028a501d2db85793a6996205c8de120944f50a0d570438fcae0457a5f9d1f8", + "https://bcr.bazel.build/modules/buildozer/8.2.1/MODULE.bazel": "61e9433c574c2bd9519cad7fa66b9c1d2b8e8d5f3ae5d6528a2c2d26e68d874d", + "https://bcr.bazel.build/modules/buildozer/8.2.1/source.json": "7c33f6a26ee0216f85544b4bca5e9044579e0219b6898dd653f5fb449cf2e484", "https://bcr.bazel.build/modules/gawk/5.3.2.bcr.1/MODULE.bazel": "cdf8cbe5ee750db04b78878c9633cc76e80dcf4416cbe982ac3a9222f80713c8", "https://bcr.bazel.build/modules/gawk/5.3.2.bcr.1/source.json": "fa7b512dfcb5eafd90ce3959cf42a2a6fe96144ebbb4b3b3928054895f2afac2", "https://bcr.bazel.build/modules/google_benchmark/1.8.2/MODULE.bazel": "a70cf1bba851000ba93b58ae2f6d76490a9feb74192e57ab8e8ff13c34ec50cb", "https://bcr.bazel.build/modules/googletest/1.11.0/MODULE.bazel": "3a83f095183f66345ca86aa13c58b59f9f94a2f81999c093d4eeaa2d262d12f4", "https://bcr.bazel.build/modules/googletest/1.14.0.bcr.1/MODULE.bazel": "22c31a561553727960057361aa33bf20fb2e98584bc4fec007906e27053f80c6", - "https://bcr.bazel.build/modules/googletest/1.14.0.bcr.1/source.json": "41e9e129f80d8c8bf103a7acc337b76e54fad1214ac0a7084bf24f4cd924b8b4", "https://bcr.bazel.build/modules/googletest/1.14.0/MODULE.bazel": "cfbcbf3e6eac06ef9d85900f64424708cc08687d1b527f0ef65aa7517af8118f", + "https://bcr.bazel.build/modules/googletest/1.15.2/MODULE.bazel": "6de1edc1d26cafb0ea1a6ab3f4d4192d91a312fd2d360b63adaa213cd00b2108", + "https://bcr.bazel.build/modules/googletest/1.17.0/MODULE.bazel": "dbec758171594a705933a29fcf69293d2468c49ec1f2ebca65c36f504d72df46", + "https://bcr.bazel.build/modules/googletest/1.17.0/source.json": "38e4454b25fc30f15439c0378e57909ab1fd0a443158aa35aec685da727cd713", "https://bcr.bazel.build/modules/jq.bzl/0.1.0/MODULE.bazel": "2ce69b1af49952cd4121a9c3055faa679e748ce774c7f1fda9657f936cae902f", "https://bcr.bazel.build/modules/jq.bzl/0.1.0/source.json": "746bf13cac0860f091df5e4911d0c593971cd8796b5ad4e809b2f8e133eee3d5", "https://bcr.bazel.build/modules/jsoncpp/1.9.5/MODULE.bazel": "31271aedc59e815656f5736f282bb7509a97c7ecb43e927ac1a37966e0578075", - "https://bcr.bazel.build/modules/jsoncpp/1.9.5/source.json": "4108ee5085dd2885a341c7fab149429db457b3169b86eb081fa245eadf69169d", + "https://bcr.bazel.build/modules/jsoncpp/1.9.6/MODULE.bazel": "2f8d20d3b7d54143213c4dfc3d98225c42de7d666011528dc8fe91591e2e17b0", + "https://bcr.bazel.build/modules/jsoncpp/1.9.6/source.json": "a04756d367a2126c3541682864ecec52f92cdee80a35735a3cb249ce015ca000", "https://bcr.bazel.build/modules/libpfm/4.11.0/MODULE.bazel": "45061ff025b301940f1e30d2c16bea596c25b176c8b6b3087e92615adbd52902", + "https://bcr.bazel.build/modules/nlohmann_json/3.6.1/MODULE.bazel": "6f7b417dcc794d9add9e556673ad25cb3ba835224290f4f848f8e2db1e1fca74", + "https://bcr.bazel.build/modules/nlohmann_json/3.6.1/source.json": "f448c6e8963fdfa7eb831457df83ad63d3d6355018f6574fb017e8169deb43a9", "https://bcr.bazel.build/modules/openssl/3.5.4.bcr.0/MODULE.bazel": "0f6b8f20b192b9ff0781406256150bcd46f19e66d807dcb0c540548439d6fc35", "https://bcr.bazel.build/modules/openssl/3.5.4.bcr.0/source.json": "543ed7627cc18e6460b9c1ae4a1b6b1debc5a5e0aca878b00f7531c7186b73da", "https://bcr.bazel.build/modules/package_metadata/0.0.2/MODULE.bazel": "fb8d25550742674d63d7b250063d4580ca530499f045d70748b1b142081ebb92", @@ -83,21 +101,28 @@ "https://bcr.bazel.build/modules/platforms/1.0.0/source.json": "f4ff1fd412e0246fd38c82328eb209130ead81d62dcd5a9e40910f867f733d96", "https://bcr.bazel.build/modules/protobuf/21.7/MODULE.bazel": "a5a29bb89544f9b97edce05642fac225a808b5b7be74038ea3640fae2f8e66a7", "https://bcr.bazel.build/modules/protobuf/27.0/MODULE.bazel": "7873b60be88844a0a1d8f80b9d5d20cfbd8495a689b8763e76c6372998d3f64c", - "https://bcr.bazel.build/modules/protobuf/27.1/MODULE.bazel": "703a7b614728bb06647f965264967a8ef1c39e09e8f167b3ca0bb1fd80449c0d", "https://bcr.bazel.build/modules/protobuf/29.0-rc2/MODULE.bazel": "6241d35983510143049943fc0d57937937122baf1b287862f9dc8590fc4c37df", - "https://bcr.bazel.build/modules/protobuf/29.0/MODULE.bazel": "319dc8bf4c679ff87e71b1ccfb5a6e90a6dbc4693501d471f48662ac46d04e4e", - "https://bcr.bazel.build/modules/protobuf/29.0/source.json": "b857f93c796750eef95f0d61ee378f3420d00ee1dd38627b27193aa482f4f981", + "https://bcr.bazel.build/modules/protobuf/29.0-rc3/MODULE.bazel": "33c2dfa286578573afc55a7acaea3cada4122b9631007c594bf0729f41c8de92", + "https://bcr.bazel.build/modules/protobuf/29.1/MODULE.bazel": "557c3457560ff49e122ed76c0bc3397a64af9574691cb8201b4e46d4ab2ecb95", "https://bcr.bazel.build/modules/protobuf/3.19.0/MODULE.bazel": "6b5fbb433f760a99a22b18b6850ed5784ef0e9928a72668b66e4d7ccd47db9b0", + "https://bcr.bazel.build/modules/protobuf/32.1/MODULE.bazel": "89cd2866a9cb07fee9ff74c41ceace11554f32e0d849de4e23ac55515cfada4d", + "https://bcr.bazel.build/modules/protobuf/33.4/MODULE.bazel": "114775b816b38b6d0ca620450d6b02550c60ceedfdc8d9a229833b34a223dc42", + "https://bcr.bazel.build/modules/protobuf/33.4/source.json": "555f8686b4c7d6b5ba731fbea13bf656b4bfd9a7ff629c1d9d3f6e1d6155de79", "https://bcr.bazel.build/modules/pybind11_bazel/2.11.1/MODULE.bazel": "88af1c246226d87e65be78ed49ecd1e6f5e98648558c14ce99176da041dc378e", - "https://bcr.bazel.build/modules/pybind11_bazel/2.11.1/source.json": "be4789e951dd5301282729fe3d4938995dc4c1a81c2ff150afc9f1b0504c6022", + "https://bcr.bazel.build/modules/pybind11_bazel/2.12.0/MODULE.bazel": "e6f4c20442eaa7c90d7190d8dc539d0ab422f95c65a57cc59562170c58ae3d34", + "https://bcr.bazel.build/modules/pybind11_bazel/2.12.0/source.json": "6900fdc8a9e95866b8c0d4ad4aba4d4236317b5c1cd04c502df3f0d33afed680", "https://bcr.bazel.build/modules/re2/2023-09-01/MODULE.bazel": "cb3d511531b16cfc78a225a9e2136007a48cf8a677e4264baeab57fe78a80206", - "https://bcr.bazel.build/modules/re2/2023-09-01/source.json": "e044ce89c2883cd957a2969a43e79f7752f9656f6b20050b62f90ede21ec6eb4", + "https://bcr.bazel.build/modules/re2/2024-07-02.bcr.1/MODULE.bazel": "b4963dda9b31080be1905ef085ecd7dd6cd47c05c79b9cdf83ade83ab2ab271a", + "https://bcr.bazel.build/modules/re2/2024-07-02.bcr.1/source.json": "2ff292be6ef3340325ce8a045ecc326e92cbfab47c7cbab4bd85d28971b97ac4", + "https://bcr.bazel.build/modules/re2/2024-07-02/MODULE.bazel": "0eadc4395959969297cbcf31a249ff457f2f1d456228c67719480205aa306daa", "https://bcr.bazel.build/modules/rules_android/0.1.1/MODULE.bazel": "48809ab0091b07ad0182defb787c4c5328bd3a278938415c00a7b69b50c4d3a8", "https://bcr.bazel.build/modules/rules_android/0.1.1/source.json": "e6986b41626ee10bdc864937ffb6d6bf275bb5b9c65120e6137d56e6331f089e", + "https://bcr.bazel.build/modules/rules_apple/3.16.0/MODULE.bazel": "0d1caf0b8375942ce98ea944be754a18874041e4e0459401d925577624d3a54a", + "https://bcr.bazel.build/modules/rules_apple/4.1.0/MODULE.bazel": "76e10fd4a48038d3fc7c5dc6e63b7063bbf5304a2e3bd42edda6ec660eebea68", + "https://bcr.bazel.build/modules/rules_apple/4.1.0/source.json": "8ee81e1708756f81b343a5eb2b2f0b953f1d25c4ab3d4a68dc02754872e80715", "https://bcr.bazel.build/modules/rules_cc/0.0.1/MODULE.bazel": "cb2aa0747f84c6c3a78dad4e2049c154f08ab9d166b1273835a8174940365647", "https://bcr.bazel.build/modules/rules_cc/0.0.10/MODULE.bazel": "ec1705118f7eaedd6e118508d3d26deba2a4e76476ada7e0e3965211be012002", "https://bcr.bazel.build/modules/rules_cc/0.0.13/MODULE.bazel": "0e8529ed7b323dad0775ff924d2ae5af7640b23553dfcd4d34344c7e7a867191", - "https://bcr.bazel.build/modules/rules_cc/0.0.14/MODULE.bazel": "5e343a3aac88b8d7af3b1b6d2093b55c347b8eefc2e7d1442f7a02dc8fea48ac", "https://bcr.bazel.build/modules/rules_cc/0.0.15/MODULE.bazel": "6704c35f7b4a72502ee81f61bf88706b54f06b3cbe5558ac17e2e14666cd5dcc", "https://bcr.bazel.build/modules/rules_cc/0.0.16/MODULE.bazel": "7661303b8fc1b4d7f532e54e9d6565771fea666fbdf839e0a86affcd02defe87", "https://bcr.bazel.build/modules/rules_cc/0.0.17/MODULE.bazel": "2ae1d8f4238ec67d7185d8861cb0a2cdf4bc608697c331b95bf990e69b62e64a", @@ -106,35 +131,37 @@ "https://bcr.bazel.build/modules/rules_cc/0.0.8/MODULE.bazel": "964c85c82cfeb6f3855e6a07054fdb159aced38e99a5eecf7bce9d53990afa3e", "https://bcr.bazel.build/modules/rules_cc/0.0.9/MODULE.bazel": "836e76439f354b89afe6a911a7adf59a6b2518fafb174483ad78a2a2fde7b1c5", "https://bcr.bazel.build/modules/rules_cc/0.1.1/MODULE.bazel": "2f0222a6f229f0bf44cd711dc13c858dad98c62d52bd51d8fc3a764a83125513", + "https://bcr.bazel.build/modules/rules_cc/0.1.2/MODULE.bazel": "557ddc3a96858ec0d465a87c0a931054d7dcfd6583af2c7ed3baf494407fd8d0", + "https://bcr.bazel.build/modules/rules_cc/0.1.5/MODULE.bazel": "88dfc9361e8b5ae1008ac38f7cdfd45ad738e4fa676a3ad67d19204f045a1fd8", + "https://bcr.bazel.build/modules/rules_cc/0.2.0/MODULE.bazel": "b5c17f90458caae90d2ccd114c81970062946f49f355610ed89bebf954f5783c", + "https://bcr.bazel.build/modules/rules_cc/0.2.13/MODULE.bazel": "eecdd666eda6be16a8d9dc15e44b5c75133405e820f620a234acc4b1fdc5aa37", + "https://bcr.bazel.build/modules/rules_cc/0.2.14/MODULE.bazel": "353c99ed148887ee89c54a17d4100ae7e7e436593d104b668476019023b58df8", "https://bcr.bazel.build/modules/rules_cc/0.2.16/MODULE.bazel": "9242fa89f950c6ef7702801ab53922e99c69b02310c39fb6e62b2bd30df2a1d4", "https://bcr.bazel.build/modules/rules_cc/0.2.16/source.json": "d03d5cde49376d87e14ec14b666c56075e5e3926930327fd5d0484a1ff2ac1cc", "https://bcr.bazel.build/modules/rules_cc/0.2.4/MODULE.bazel": "1ff1223dfd24f3ecf8f028446d4a27608aa43c3f41e346d22838a4223980b8cc", "https://bcr.bazel.build/modules/rules_cc/0.2.8/MODULE.bazel": "f1df20f0bf22c28192a794f29b501ee2018fa37a3862a1a2132ae2940a23a642", "https://bcr.bazel.build/modules/rules_foreign_cc/0.9.0/MODULE.bazel": "c9e8c682bf75b0e7c704166d79b599f93b72cfca5ad7477df596947891feeef6", "https://bcr.bazel.build/modules/rules_fuzzing/0.5.2/MODULE.bazel": "40c97d1144356f52905566c55811f13b299453a14ac7769dfba2ac38192337a8", - "https://bcr.bazel.build/modules/rules_fuzzing/0.5.2/source.json": "c8b1e2c717646f1702290959a3302a178fb639d987ab61d548105019f11e527e", "https://bcr.bazel.build/modules/rules_java/4.0.0/MODULE.bazel": "5a78a7ae82cd1a33cef56dc578c7d2a46ed0dca12643ee45edbb8417899e6f74", "https://bcr.bazel.build/modules/rules_java/5.3.5/MODULE.bazel": "a4ec4f2db570171e3e5eb753276ee4b389bae16b96207e9d3230895c99644b86", - "https://bcr.bazel.build/modules/rules_java/6.0.0/MODULE.bazel": "8a43b7df601a7ec1af61d79345c17b31ea1fedc6711fd4abfd013ea612978e39", "https://bcr.bazel.build/modules/rules_java/6.3.0/MODULE.bazel": "a97c7678c19f236a956ad260d59c86e10a463badb7eb2eda787490f4c969b963", - "https://bcr.bazel.build/modules/rules_java/6.4.0/MODULE.bazel": "e986a9fe25aeaa84ac17ca093ef13a4637f6107375f64667a15999f77db6c8f6", "https://bcr.bazel.build/modules/rules_java/6.5.2/MODULE.bazel": "1d440d262d0e08453fa0c4d8f699ba81609ed0e9a9a0f02cd10b3e7942e61e31", "https://bcr.bazel.build/modules/rules_java/7.10.0/MODULE.bazel": "530c3beb3067e870561739f1144329a21c851ff771cd752a49e06e3dc9c2e71a", "https://bcr.bazel.build/modules/rules_java/7.12.2/MODULE.bazel": "579c505165ee757a4280ef83cda0150eea193eed3bef50b1004ba88b99da6de6", "https://bcr.bazel.build/modules/rules_java/7.2.0/MODULE.bazel": "06c0334c9be61e6cef2c8c84a7800cef502063269a5af25ceb100b192453d4ab", - "https://bcr.bazel.build/modules/rules_java/7.3.2/MODULE.bazel": "50dece891cfdf1741ea230d001aa9c14398062f2b7c066470accace78e412bc2", "https://bcr.bazel.build/modules/rules_java/7.6.1/MODULE.bazel": "2f14b7e8a1aa2f67ae92bc69d1ec0fa8d9f827c4e17ff5e5f02e91caa3b2d0fe", - "https://bcr.bazel.build/modules/rules_java/8.14.0/MODULE.bazel": "717717ed40cc69994596a45aec6ea78135ea434b8402fb91b009b9151dd65615", - "https://bcr.bazel.build/modules/rules_java/8.14.0/source.json": "8a88c4ca9e8759da53cddc88123880565c520503321e2566b4e33d0287a3d4bc", + "https://bcr.bazel.build/modules/rules_java/8.3.2/MODULE.bazel": "7336d5511ad5af0b8615fdc7477535a2e4e723a357b6713af439fe8cf0195017", + "https://bcr.bazel.build/modules/rules_java/8.5.1/MODULE.bazel": "d8a9e38cc5228881f7055a6079f6f7821a073df3744d441978e7a43e20226939", "https://bcr.bazel.build/modules/rules_java/8.6.0/MODULE.bazel": "9c064c434606d75a086f15ade5edb514308cccd1544c2b2a89bbac4310e41c71", + "https://bcr.bazel.build/modules/rules_java/8.6.1/MODULE.bazel": "f4808e2ab5b0197f094cabce9f4b006a27766beb6a9975931da07099560ca9c2", + "https://bcr.bazel.build/modules/rules_java/9.0.3/MODULE.bazel": "1f98ed015f7e744a745e0df6e898a7c5e83562d6b759dfd475c76456dda5ccea", + "https://bcr.bazel.build/modules/rules_java/9.0.3/source.json": "b038c0c07e12e658135bbc32cc1a2ded6e33785105c9d41958014c592de4593e", "https://bcr.bazel.build/modules/rules_jvm_external/4.4.2/MODULE.bazel": "a56b85e418c83eb1839819f0b515c431010160383306d13ec21959ac412d2fe7", "https://bcr.bazel.build/modules/rules_jvm_external/5.1/MODULE.bazel": "33f6f999e03183f7d088c9be518a63467dfd0be94a11d0055fe2d210f89aa909", "https://bcr.bazel.build/modules/rules_jvm_external/5.2/MODULE.bazel": "d9351ba35217ad0de03816ef3ed63f89d411349353077348a45348b096615036", - "https://bcr.bazel.build/modules/rules_jvm_external/5.3/MODULE.bazel": "bf93870767689637164657731849fb887ad086739bd5d360d90007a581d5527d", - "https://bcr.bazel.build/modules/rules_jvm_external/6.1/MODULE.bazel": "75b5fec090dbd46cf9b7d8ea08cf84a0472d92ba3585b476f44c326eda8059c4", "https://bcr.bazel.build/modules/rules_jvm_external/6.3/MODULE.bazel": "c998e060b85f71e00de5ec552019347c8bca255062c990ac02d051bb80a38df0", - "https://bcr.bazel.build/modules/rules_jvm_external/6.3/source.json": "6f5f5a5a4419ae4e37c35a5bb0a6ae657ed40b7abc5a5189111b47fcebe43197", - "https://bcr.bazel.build/modules/rules_kotlin/1.9.0/MODULE.bazel": "ef85697305025e5a61f395d4eaede272a5393cee479ace6686dba707de804d59", + "https://bcr.bazel.build/modules/rules_jvm_external/6.7/MODULE.bazel": "e717beabc4d091ecb2c803c2d341b88590e9116b8bf7947915eeb33aab4f96dd", + "https://bcr.bazel.build/modules/rules_jvm_external/6.7/source.json": "5426f412d0a7fc6b611643376c7e4a82dec991491b9ce5cb1cfdd25fe2e92be4", "https://bcr.bazel.build/modules/rules_kotlin/1.9.6/MODULE.bazel": "d269a01a18ee74d0335450b10f62c9ed81f2321d7958a2934e44272fe82dcef3", "https://bcr.bazel.build/modules/rules_kotlin/1.9.6/source.json": "2faa4794364282db7c06600b7e5e34867a564ae91bda7cae7c29c64e9466b7d5", "https://bcr.bazel.build/modules/rules_license/0.0.3/MODULE.bazel": "627e9ab0247f7d1e05736b59dbb1b6871373de5ad31c3011880b4133cafd4bd0", @@ -150,34 +177,47 @@ "https://bcr.bazel.build/modules/rules_platform/0.1.0/source.json": "98becf9569572719b65f639133510633eb3527fb37d347d7ef08447f3ebcf1c9", "https://bcr.bazel.build/modules/rules_proto/4.0.0/MODULE.bazel": "a7a7b6ce9bee418c1a760b3d84f83a299ad6952f9903c67f19e4edd964894e06", "https://bcr.bazel.build/modules/rules_proto/5.3.0-21.7/MODULE.bazel": "e8dff86b0971688790ae75528fe1813f71809b5afd57facb44dad9e8eca631b7", + "https://bcr.bazel.build/modules/rules_proto/6.0.0-rc1/MODULE.bazel": "1e5b502e2e1a9e825eef74476a5a1ee524a92297085015a052510b09a1a09483", "https://bcr.bazel.build/modules/rules_proto/6.0.2/MODULE.bazel": "ce916b775a62b90b61888052a416ccdda405212b6aaeb39522f7dc53431a5e73", - "https://bcr.bazel.build/modules/rules_proto/7.0.2/MODULE.bazel": "bf81793bd6d2ad89a37a40693e56c61b0ee30f7a7fdbaf3eabbf5f39de47dea2", - "https://bcr.bazel.build/modules/rules_proto/7.0.2/source.json": "1e5e7260ae32ef4f2b52fd1d0de8d03b606a44c91b694d2f1afb1d3b28a48ce1", + "https://bcr.bazel.build/modules/rules_proto/7.1.0/MODULE.bazel": "002d62d9108f75bb807cd56245d45648f38275cb3a99dcd45dfb864c5d74cb96", + "https://bcr.bazel.build/modules/rules_proto/7.1.0/source.json": "39f89066c12c24097854e8f57ab8558929f9c8d474d34b2c00ac04630ad8940e", "https://bcr.bazel.build/modules/rules_python/0.10.2/MODULE.bazel": "cc82bc96f2997baa545ab3ce73f196d040ffb8756fd2d66125a530031cd90e5f", "https://bcr.bazel.build/modules/rules_python/0.23.1/MODULE.bazel": "49ffccf0511cb8414de28321f5fcf2a31312b47c40cc21577144b7447f2bf300", "https://bcr.bazel.build/modules/rules_python/0.25.0/MODULE.bazel": "72f1506841c920a1afec76975b35312410eea3aa7b63267436bfb1dd91d2d382", "https://bcr.bazel.build/modules/rules_python/0.28.0/MODULE.bazel": "cba2573d870babc976664a912539b320cbaa7114cd3e8f053c720171cde331ed", "https://bcr.bazel.build/modules/rules_python/0.31.0/MODULE.bazel": "93a43dc47ee570e6ec9f5779b2e64c1476a6ce921c48cc9a1678a91dd5f8fd58", + "https://bcr.bazel.build/modules/rules_python/0.33.2/MODULE.bazel": "3e036c4ad8d804a4dad897d333d8dce200d943df4827cb849840055be8d2e937", "https://bcr.bazel.build/modules/rules_python/0.4.0/MODULE.bazel": "9208ee05fd48bf09ac60ed269791cf17fb343db56c8226a720fbb1cdf467166c", - "https://bcr.bazel.build/modules/rules_python/0.40.0/MODULE.bazel": "9d1a3cd88ed7d8e39583d9ffe56ae8a244f67783ae89b60caafc9f5cf318ada7", - "https://bcr.bazel.build/modules/rules_python/0.40.0/source.json": "939d4bd2e3110f27bfb360292986bb79fd8dcefb874358ccd6cdaa7bda029320", + "https://bcr.bazel.build/modules/rules_python/1.3.0/MODULE.bazel": "8361d57eafb67c09b75bf4bbe6be360e1b8f4f18118ab48037f2bd50aa2ccb13", + "https://bcr.bazel.build/modules/rules_python/1.4.1/MODULE.bazel": "8991ad45bdc25018301d6b7e1d3626afc3c8af8aaf4bc04f23d0b99c938b73a6", + "https://bcr.bazel.build/modules/rules_python/1.6.0/MODULE.bazel": "7e04ad8f8d5bea40451cf80b1bd8262552aa73f841415d20db96b7241bd027d8", + "https://bcr.bazel.build/modules/rules_python/1.7.0/MODULE.bazel": "d01f995ecd137abf30238ad9ce97f8fc3ac57289c8b24bd0bf53324d937a14f8", + "https://bcr.bazel.build/modules/rules_python/1.7.0/source.json": "028a084b65dcf8f4dc4f82f8778dbe65df133f234b316828a82e060d81bdce32", "https://bcr.bazel.build/modules/rules_rs/0.0.23/MODULE.bazel": "2e7ae2044105b1873a451c628713329d6746493f677b371f9d8063fd06a00937", "https://bcr.bazel.build/modules/rules_rs/0.0.23/source.json": "1149e7f599f2e41e9e9de457f9c4deb3d219a4fec967cea30557d02ede88037e", "https://bcr.bazel.build/modules/rules_rust/0.66.0/MODULE.bazel": "86ef763a582f4739a27029bdcc6c562258ed0ea6f8d58294b049e215ceb251b3", "https://bcr.bazel.build/modules/rules_rust/0.68.1/MODULE.bazel": "8d3332ef4079673385eb81f8bd68b012decc04ac00c9d5a01a40eff90301732c", "https://bcr.bazel.build/modules/rules_rust/0.68.1/source.json": "3378e746f81b62457fdfd37391244fa8ff075ba85c05931ee4f3a20ac1efe963", "https://bcr.bazel.build/modules/rules_shell/0.2.0/MODULE.bazel": "fda8a652ab3c7d8fee214de05e7a9916d8b28082234e8d2c0094505c5268ed3c", + "https://bcr.bazel.build/modules/rules_shell/0.3.0/MODULE.bazel": "de4402cd12f4cc8fda2354fce179fdb068c0b9ca1ec2d2b17b3e21b24c1a937b", "https://bcr.bazel.build/modules/rules_shell/0.4.0/MODULE.bazel": "0f8f11bb3cd11755f0b48c1de0bbcf62b4b34421023aa41a2fc74ef68d9584f0", "https://bcr.bazel.build/modules/rules_shell/0.4.1/MODULE.bazel": "00e501db01bbf4e3e1dd1595959092c2fadf2087b2852d3f553b5370f5633592", "https://bcr.bazel.build/modules/rules_shell/0.6.1/MODULE.bazel": "72e76b0eea4e81611ef5452aa82b3da34caca0c8b7b5c0c9584338aa93bae26b", "https://bcr.bazel.build/modules/rules_shell/0.6.1/source.json": "20ec05cd5e592055e214b2da8ccb283c7f2a421ea0dc2acbf1aa792e11c03d0c", + "https://bcr.bazel.build/modules/rules_swift/1.16.0/MODULE.bazel": "4a09f199545a60d09895e8281362b1ff3bb08bbde69c6fc87aff5b92fcc916ca", + "https://bcr.bazel.build/modules/rules_swift/2.1.1/MODULE.bazel": "494900a80f944fc7aa61500c2073d9729dff0b764f0e89b824eb746959bc1046", + "https://bcr.bazel.build/modules/rules_swift/2.4.0/MODULE.bazel": "1639617eb1ede28d774d967a738b4a68b0accb40650beadb57c21846beab5efd", + "https://bcr.bazel.build/modules/rules_swift/3.1.2/MODULE.bazel": "72c8f5cf9d26427cee6c76c8e3853eb46ce6b0412a081b2b6db6e8ad56267400", + "https://bcr.bazel.build/modules/rules_swift/3.1.2/source.json": "e85761f3098a6faf40b8187695e3de6d97944e98abd0d8ce579cb2daf6319a66", "https://bcr.bazel.build/modules/stardoc/0.5.1/MODULE.bazel": "1a05d92974d0c122f5ccf09291442580317cdd859f07a8655f1db9a60374f9f8", "https://bcr.bazel.build/modules/stardoc/0.5.3/MODULE.bazel": "c7f6948dae6999bf0db32c1858ae345f112cacf98f174c7a8bb707e41b974f1c", - "https://bcr.bazel.build/modules/stardoc/0.5.6/MODULE.bazel": "c43dabc564990eeab55e25ed61c07a1aadafe9ece96a4efabb3f8bf9063b71ef", "https://bcr.bazel.build/modules/stardoc/0.6.2/MODULE.bazel": "7060193196395f5dd668eda046ccbeacebfd98efc77fed418dbe2b82ffaa39fd", "https://bcr.bazel.build/modules/stardoc/0.7.0/MODULE.bazel": "05e3d6d30c099b6770e97da986c53bd31844d7f13d41412480ea265ac9e8079c", - "https://bcr.bazel.build/modules/stardoc/0.7.1/MODULE.bazel": "3548faea4ee5dda5580f9af150e79d0f6aea934fc60c1cc50f4efdd9420759e7", - "https://bcr.bazel.build/modules/stardoc/0.7.1/source.json": "b6500ffcd7b48cd72c29bb67bcac781e12701cc0d6d55d266a652583cfcdab01", + "https://bcr.bazel.build/modules/stardoc/0.7.2/MODULE.bazel": "fc152419aa2ea0f51c29583fab1e8c99ddefd5b3778421845606ee628629e0e5", + "https://bcr.bazel.build/modules/stardoc/0.7.2/source.json": "58b029e5e901d6802967754adf0a9056747e8176f017cfe3607c0851f4d42216", + "https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.1/MODULE.bazel": "5e463fbfba7b1701d957555ed45097d7f984211330106ccd1352c6e0af0dcf91", + "https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.2/MODULE.bazel": "75aab2373a4bbe2a1260b9bf2a1ebbdbf872d3bd36f80bff058dccd82e89422f", + "https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.2/source.json": "5fba48bbe0ba48761f9e9f75f92876cafb5d07c0ce059cc7a8027416de94a05b", "https://bcr.bazel.build/modules/tar.bzl/0.2.1/MODULE.bazel": "52d1c00a80a8cc67acbd01649e83d8dd6a9dc426a6c0b754a04fe8c219c76468", "https://bcr.bazel.build/modules/tar.bzl/0.6.0/MODULE.bazel": "a3584b4edcfafcabd9b0ef9819808f05b372957bbdff41601429d5fd0aac2e7c", "https://bcr.bazel.build/modules/tar.bzl/0.6.0/source.json": "4a620381df075a16cb3a7ed57bd1d05f7480222394c64a20fa51bdb636fda658", @@ -197,9 +237,10 @@ "general": { "bzlTransitiveDigest": "dnnhvKMf9MIXMulhbhHBblZdDAfAkiSVjApIXpUz9Y8=", "usagesDigest": "dPuxg6asjUidjHZi+xFfMiW+r9RawVYGjTZnOeP+fLI=", - "recordedFileInputs": {}, - "recordedDirentsInputs": {}, - "envVariables": {}, + "recordedInputs": [ + "REPO_MAPPING:aspect_tools_telemetry+,bazel_lib bazel_lib+", + "REPO_MAPPING:aspect_tools_telemetry+,bazel_skylib bazel_skylib+" + ], "generatedRepoSpecs": { "aspect_tools_telemetry_report": { "repoRuleId": "@@aspect_tools_telemetry+//:extension.bzl%tel_repository", @@ -246,83 +287,7 @@ } } } - }, - "recordedRepoMappingEntries": [ - [ - "aspect_tools_telemetry+", - "bazel_lib", - "bazel_lib+" - ], - [ - "aspect_tools_telemetry+", - "bazel_skylib", - "bazel_skylib+" - ] - ] - } - }, - "@@rules_kotlin+//src/main/starlark/core/repositories:bzlmod_setup.bzl%rules_kotlin_extensions": { - "general": { - "bzlTransitiveDigest": "rL/34P1aFDq2GqVC2zCFgQ8nTuOC6ziogocpvG50Qz8=", - "usagesDigest": "QI2z8ZUR+mqtbwsf2fLqYdJAkPOHdOV+tF2yVAUgRzw=", - "recordedFileInputs": {}, - "recordedDirentsInputs": {}, - "envVariables": {}, - "generatedRepoSpecs": { - "com_github_jetbrains_kotlin_git": { - "repoRuleId": "@@rules_kotlin+//src/main/starlark/core/repositories:compiler.bzl%kotlin_compiler_git_repository", - "attributes": { - "urls": [ - "https://github.com/JetBrains/kotlin/releases/download/v1.9.23/kotlin-compiler-1.9.23.zip" - ], - "sha256": "93137d3aab9afa9b27cb06a824c2324195c6b6f6179d8a8653f440f5bd58be88" - } - }, - "com_github_jetbrains_kotlin": { - "repoRuleId": "@@rules_kotlin+//src/main/starlark/core/repositories:compiler.bzl%kotlin_capabilities_repository", - "attributes": { - "git_repository_name": "com_github_jetbrains_kotlin_git", - "compiler_version": "1.9.23" - } - }, - "com_github_google_ksp": { - "repoRuleId": "@@rules_kotlin+//src/main/starlark/core/repositories:ksp.bzl%ksp_compiler_plugin_repository", - "attributes": { - "urls": [ - "https://github.com/google/ksp/releases/download/1.9.23-1.0.20/artifacts.zip" - ], - "sha256": "ee0618755913ef7fd6511288a232e8fad24838b9af6ea73972a76e81053c8c2d", - "strip_version": "1.9.23-1.0.20" - } - }, - "com_github_pinterest_ktlint": { - "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_file", - "attributes": { - "sha256": "01b2e0ef893383a50dbeb13970fe7fa3be36ca3e83259e01649945b09d736985", - "urls": [ - "https://github.com/pinterest/ktlint/releases/download/1.3.0/ktlint" - ], - "executable": true - } - }, - "rules_android": { - "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", - "attributes": { - "sha256": "cd06d15dd8bb59926e4d65f9003bfc20f9da4b2519985c27e190cddc8b7a7806", - "strip_prefix": "rules_android-0.1.1", - "urls": [ - "https://github.com/bazelbuild/rules_android/archive/v0.1.1.zip" - ] - } - } - }, - "recordedRepoMappingEntries": [ - [ - "rules_kotlin+", - "bazel_tools", - "bazel_tools" - ] - ] + } } } }, @@ -355,7 +320,7 @@ "anstyle_1.0.11": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"lexopt\",\"req\":\"^0.3.0\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "anyhow_1.0.100": "{\"dependencies\":[{\"name\":\"backtrace\",\"optional\":true,\"req\":\"^0.3.51\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.6\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"syn\",\"req\":\"^2.0\"},{\"kind\":\"dev\",\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.108\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "arboard_3.6.1": "{\"dependencies\":[{\"features\":[\"std\"],\"name\":\"clipboard-win\",\"req\":\"^5.3.1\",\"target\":\"cfg(windows)\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10.2\"},{\"default_features\":false,\"features\":[\"png\"],\"name\":\"image\",\"optional\":true,\"req\":\"^0.25\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"},{\"default_features\":false,\"features\":[\"tiff\"],\"name\":\"image\",\"optional\":true,\"req\":\"^0.25\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"default_features\":false,\"features\":[\"png\",\"bmp\"],\"name\":\"image\",\"optional\":true,\"req\":\"^0.25\",\"target\":\"cfg(windows)\"},{\"name\":\"log\",\"req\":\"^0.4\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"},{\"name\":\"log\",\"req\":\"^0.4\",\"target\":\"cfg(windows)\"},{\"name\":\"objc2\",\"req\":\"^0.6.0\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"default_features\":false,\"features\":[\"std\",\"objc2-core-graphics\",\"NSPasteboard\",\"NSPasteboardItem\",\"NSImage\"],\"name\":\"objc2-app-kit\",\"req\":\"^0.3.0\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"default_features\":false,\"features\":[\"std\",\"CFCGTypes\"],\"name\":\"objc2-core-foundation\",\"optional\":true,\"req\":\"^0.3.0\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"default_features\":false,\"features\":[\"std\",\"CGImage\",\"CGColorSpace\",\"CGDataProvider\"],\"name\":\"objc2-core-graphics\",\"optional\":true,\"req\":\"^0.3.0\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"default_features\":false,\"features\":[\"std\",\"NSArray\",\"NSString\",\"NSEnumerator\",\"NSGeometry\",\"NSValue\"],\"name\":\"objc2-foundation\",\"req\":\"^0.3.0\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"name\":\"parking_lot\",\"req\":\"^0.12\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"},{\"name\":\"percent-encoding\",\"req\":\"^2.3.1\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"},{\"features\":[\"Win32_Foundation\",\"Win32_Storage_FileSystem\",\"Win32_System_DataExchange\",\"Win32_System_Memory\",\"Win32_System_Ole\",\"Win32_UI_Shell\"],\"name\":\"windows-sys\",\"req\":\">=0.52.0, <0.61.0\",\"target\":\"cfg(windows)\"},{\"name\":\"wl-clipboard-rs\",\"optional\":true,\"req\":\"^0.9.0\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"},{\"name\":\"x11rb\",\"req\":\"^0.13\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"}],\"features\":{\"core-graphics\":[\"dep:objc2-core-graphics\"],\"default\":[\"image-data\"],\"image\":[\"dep:image\"],\"image-data\":[\"dep:objc2-core-graphics\",\"dep:objc2-core-foundation\",\"image\",\"windows-sys\",\"core-graphics\"],\"wayland-data-control\":[\"wl-clipboard-rs\"],\"windows-sys\":[\"windows-sys/Win32_Graphics_Gdi\"],\"wl-clipboard-rs\":[\"dep:wl-clipboard-rs\"]}}", - "arc-swap_1.7.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"adaptive-barrier\",\"req\":\"~1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"~0.5\"},{\"kind\":\"dev\",\"name\":\"crossbeam-utils\",\"req\":\"~0.8\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.12\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"~1\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"~1\"},{\"kind\":\"dev\",\"name\":\"parking_lot\",\"req\":\"~0.12\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"features\":[\"rc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.130\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.130\"}],\"features\":{\"experimental-strategies\":[],\"experimental-thread-local\":[],\"internal-test-strategies\":[],\"weak\":[]}}", + "arc-swap_1.8.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"adaptive-barrier\",\"req\":\"~1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"~0.7\"},{\"kind\":\"dev\",\"name\":\"crossbeam-utils\",\"req\":\"~0.8\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.14\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"~1\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"~1\"},{\"kind\":\"dev\",\"name\":\"parking_lot\",\"req\":\"~0.12\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"name\":\"rustversion\",\"req\":\"^1\"},{\"features\":[\"rc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.130\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.177\"}],\"features\":{\"experimental-strategies\":[],\"experimental-thread-local\":[],\"internal-test-strategies\":[],\"weak\":[]}}", "arrayvec_0.7.6": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.4\"},{\"default_features\":false,\"name\":\"borsh\",\"optional\":true,\"req\":\"^1.2.0\"},{\"kind\":\"dev\",\"name\":\"matches\",\"req\":\"^0.1\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.4\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "ascii-canvas_3.0.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"diff\",\"req\":\"^0.1\"},{\"name\":\"term\",\"req\":\"^0.7\"}],\"features\":{}}", "ascii_1.1.0": "{\"dependencies\":[{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.25\"},{\"name\":\"serde_test\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", @@ -406,7 +371,7 @@ "cfg_aliases_0.1.1": "{\"dependencies\":[],\"features\":{}}", "cfg_aliases_0.2.1": "{\"dependencies\":[],\"features\":{}}", "chardetng_0.1.17": "{\"dependencies\":[{\"name\":\"arrayvec\",\"optional\":true,\"req\":\"^0.5.1\"},{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"detone\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"name\":\"encoding_rs\",\"req\":\"^0.8.29\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2.2.0\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.3.0\"}],\"features\":{\"multithreading\":[\"rayon\",\"arrayvec\"],\"testing-only-no-semver-guarantees-do-not-use\":[]}}", - "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\"]}}", + "chrono_0.4.43": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.0\"},{\"name\":\"defmt\",\"optional\":true,\"req\":\"^1.0.1\"},{\"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.2\"},{\"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.66\"},{\"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\"],\"defmt\":[\"dep:defmt\",\"pure-rust-locales?/defmt\"],\"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.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\"]}}", @@ -439,9 +404,9 @@ "crossterm_winapi_0.9.1": "{\"dependencies\":[{\"features\":[\"winbase\",\"consoleapi\",\"processenv\",\"handleapi\",\"synchapi\",\"impl-default\"],\"name\":\"winapi\",\"req\":\"^0.3.8\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "crunchy_0.2.4": "{\"dependencies\":[],\"features\":{\"default\":[\"limit_128\"],\"limit_1024\":[],\"limit_128\":[],\"limit_2048\":[],\"limit_256\":[],\"limit_512\":[],\"limit_64\":[],\"std\":[]}}", "crypto-common_0.1.6": "{\"dependencies\":[{\"features\":[\"more_lengths\"],\"name\":\"generic-array\",\"req\":\"^0.14.4\"},{\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"typenum\",\"req\":\"^1.14\"}],\"features\":{\"getrandom\":[\"rand_core/getrandom\"],\"std\":[]}}", - "ctor-proc-macro_0.0.6": "{\"dependencies\":[],\"features\":{\"default\":[]}}", + "ctor-proc-macro_0.0.7": "{\"dependencies\":[],\"features\":{\"default\":[]}}", "ctor_0.1.26": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"libc-print\",\"req\":\"^0.1.20\"},{\"name\":\"quote\",\"req\":\"^1.0.20\"},{\"default_features\":false,\"features\":[\"full\",\"parsing\",\"printing\",\"proc-macro\"],\"name\":\"syn\",\"req\":\"^1.0.98\"}],\"features\":{}}", - "ctor_0.5.0": "{\"dependencies\":[{\"name\":\"ctor-proc-macro\",\"optional\":true,\"req\":\"=0.0.6\"},{\"default_features\":false,\"name\":\"dtor\",\"optional\":true,\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"libc-print\",\"req\":\"^0.1.20\"}],\"features\":{\"__no_warn_on_missing_unsafe\":[\"dtor?/__no_warn_on_missing_unsafe\"],\"default\":[\"dtor\",\"proc_macro\",\"__no_warn_on_missing_unsafe\"],\"dtor\":[\"dep:dtor\"],\"proc_macro\":[\"dep:ctor-proc-macro\",\"dtor?/proc_macro\"],\"used_linker\":[\"dtor?/used_linker\"]}}", + "ctor_0.6.3": "{\"dependencies\":[{\"name\":\"ctor-proc-macro\",\"optional\":true,\"req\":\"=0.0.7\"},{\"default_features\":false,\"name\":\"dtor\",\"optional\":true,\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"libc-print\",\"req\":\"^0.1.20\"}],\"features\":{\"__no_warn_on_missing_unsafe\":[\"dtor?/__no_warn_on_missing_unsafe\"],\"default\":[\"dtor\",\"proc_macro\",\"__no_warn_on_missing_unsafe\"],\"dtor\":[\"dep:dtor\"],\"proc_macro\":[\"dep:ctor-proc-macro\",\"dtor?/proc_macro\"],\"used_linker\":[\"dtor?/used_linker\"]}}", "darling_0.20.11": "{\"dependencies\":[{\"name\":\"darling_core\",\"req\":\"=0.20.11\"},{\"name\":\"darling_macro\",\"req\":\"=0.20.11\"},{\"kind\":\"dev\",\"name\":\"proc-macro2\",\"req\":\"^1.0.86\"},{\"kind\":\"dev\",\"name\":\"quote\",\"req\":\"^1.0.18\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.9\",\"target\":\"cfg(compiletests)\"},{\"kind\":\"dev\",\"name\":\"syn\",\"req\":\"^2.0.15\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.89\",\"target\":\"cfg(compiletests)\"}],\"features\":{\"default\":[\"suggestions\"],\"diagnostics\":[\"darling_core/diagnostics\"],\"suggestions\":[\"darling_core/suggestions\"]}}", "darling_0.21.3": "{\"dependencies\":[{\"name\":\"darling_core\",\"req\":\"=0.21.3\"},{\"name\":\"darling_macro\",\"req\":\"=0.21.3\"},{\"kind\":\"dev\",\"name\":\"proc-macro2\",\"req\":\"^1.0.86\"},{\"kind\":\"dev\",\"name\":\"quote\",\"req\":\"^1.0.18\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.9\",\"target\":\"cfg(compiletests)\"},{\"kind\":\"dev\",\"name\":\"syn\",\"req\":\"^2.0.15\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.89\",\"target\":\"cfg(compiletests)\"}],\"features\":{\"default\":[\"suggestions\"],\"diagnostics\":[\"darling_core/diagnostics\"],\"serde\":[\"darling_core/serde\"],\"suggestions\":[\"darling_core/suggestions\"]}}", "darling_0.23.0": "{\"dependencies\":[{\"name\":\"darling_core\",\"req\":\"=0.23.0\"},{\"name\":\"darling_macro\",\"req\":\"=0.23.0\"},{\"kind\":\"dev\",\"name\":\"proc-macro2\",\"req\":\"^1.0.86\"},{\"kind\":\"dev\",\"name\":\"quote\",\"req\":\"^1.0.18\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.9\",\"target\":\"cfg(compiletests)\"},{\"kind\":\"dev\",\"name\":\"syn\",\"req\":\"^2.0.15\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.89\",\"target\":\"cfg(compiletests)\"}],\"features\":{\"default\":[\"suggestions\"],\"diagnostics\":[\"darling_core/diagnostics\"],\"serde\":[\"darling_core/serde\"],\"suggestions\":[\"darling_core/suggestions\"]}}", @@ -541,6 +506,8 @@ "getrandom_0.2.16": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"compiler_builtins\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0\"},{\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(all(any(target_arch = \\\"wasm32\\\", target_arch = \\\"wasm64\\\"), target_os = \\\"unknown\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(unix)\"},{\"default_features\":false,\"name\":\"wasi\",\"req\":\"^0.11\",\"target\":\"cfg(target_os = \\\"wasi\\\")\"},{\"default_features\":false,\"name\":\"wasm-bindgen\",\"optional\":true,\"req\":\"^0.2.62\",\"target\":\"cfg(all(any(target_arch = \\\"wasm32\\\", target_arch = \\\"wasm64\\\"), target_os = \\\"unknown\\\"))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.18\",\"target\":\"cfg(all(any(target_arch = \\\"wasm32\\\", target_arch = \\\"wasm64\\\"), target_os = \\\"unknown\\\"))\"}],\"features\":{\"custom\":[],\"js\":[\"wasm-bindgen\",\"js-sys\"],\"linux_disable_fallback\":[],\"rdrand\":[],\"rustc-dep-of-std\":[\"compiler_builtins\",\"core\",\"libc/rustc-dep-of-std\",\"wasi/rustc-dep-of-std\"],\"std\":[],\"test-in-browser\":[]}}", "getrandom_0.3.3": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"compiler_builtins\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3.77\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"), target_feature = \\\"atomics\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(all(any(target_os = \\\"linux\\\", target_os = \\\"android\\\"), not(any(all(target_os = \\\"linux\\\", target_env = \\\"\\\"), getrandom_backend = \\\"custom\\\", getrandom_backend = \\\"linux_raw\\\", getrandom_backend = \\\"rdrand\\\", getrandom_backend = \\\"rndr\\\"))))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(any(target_os = \\\"dragonfly\\\", target_os = \\\"freebsd\\\", target_os = \\\"hurd\\\", target_os = \\\"illumos\\\", target_os = \\\"cygwin\\\", all(target_os = \\\"horizon\\\", target_arch = \\\"arm\\\")))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(any(target_os = \\\"haiku\\\", target_os = \\\"redox\\\", target_os = \\\"nto\\\", target_os = \\\"aix\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(any(target_os = \\\"ios\\\", target_os = \\\"visionos\\\", target_os = \\\"watchos\\\", target_os = \\\"tvos\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(any(target_os = \\\"macos\\\", target_os = \\\"openbsd\\\", target_os = \\\"vita\\\", target_os = \\\"emscripten\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(target_os = \\\"netbsd\\\")\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(target_os = \\\"solaris\\\")\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(target_os = \\\"vxworks\\\")\"},{\"default_features\":false,\"name\":\"r-efi\",\"req\":\"^5.1\",\"target\":\"cfg(all(target_os = \\\"uefi\\\", getrandom_backend = \\\"efi_rng\\\"))\"},{\"default_features\":false,\"name\":\"wasi\",\"req\":\"^0.14\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"wasi\\\", target_env = \\\"p2\\\"))\"},{\"default_features\":false,\"name\":\"wasm-bindgen\",\"optional\":true,\"req\":\"^0.2.98\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"}],\"features\":{\"rustc-dep-of-std\":[\"dep:compiler_builtins\",\"dep:core\"],\"std\":[],\"wasm_js\":[\"dep:wasm-bindgen\",\"dep:js-sys\"]}}", "gimli_0.31.1": "{\"dependencies\":[{\"name\":\"alloc\",\"optional\":true,\"package\":\"rustc-std-workspace-alloc\",\"req\":\"^1.0.0\"},{\"name\":\"compiler_builtins\",\"optional\":true,\"req\":\"^0.1.2\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"name\":\"fallible-iterator\",\"optional\":true,\"req\":\"^0.3.0\"},{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.0.0\"},{\"default_features\":false,\"name\":\"stable_deref_trait\",\"optional\":true,\"req\":\"^1.1.0\"},{\"kind\":\"dev\",\"name\":\"test-assembler\",\"req\":\"^0.1.3\"}],\"features\":{\"default\":[\"read-all\",\"write\"],\"endian-reader\":[\"read\",\"dep:stable_deref_trait\"],\"fallible-iterator\":[\"dep:fallible-iterator\"],\"read\":[\"read-core\"],\"read-all\":[\"read\",\"std\",\"fallible-iterator\",\"endian-reader\"],\"read-core\":[],\"rustc-dep-of-std\":[\"dep:core\",\"dep:alloc\",\"dep:compiler_builtins\"],\"std\":[\"fallible-iterator?/std\",\"stable_deref_trait?/std\"],\"write\":[\"dep:indexmap\"]}}", + "git+https://github.com/JakkuSakura/tokio-tungstenite?rev=2ae536b0de793f3ddf31fc2f22d445bf1ef2023d#2ae536b0de793f3ddf31fc2f22d445bf1ef2023d_tokio-tungstenite": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"sink\",\"std\"],\"name\":\"futures-util\",\"optional\":false},{\"name\":\"log\"},{\"default_features\":true,\"features\":[],\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\"},{\"default_features\":false,\"features\":[],\"name\":\"rustls\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-native-certs\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-pki-types\",\"optional\":true},{\"default_features\":false,\"features\":[\"io-util\"],\"name\":\"tokio\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"tokio-native-tls\",\"optional\":true},{\"default_features\":false,\"features\":[],\"name\":\"tokio-rustls\",\"optional\":true},{\"default_features\":false,\"features\":[],\"name\":\"tungstenite\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"webpki-roots\",\"optional\":true}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\",\"tokio-rustls\",\"stream\",\"tungstenite/__rustls-tls\",\"handshake\"],\"connect\":[\"stream\",\"tokio/net\",\"handshake\"],\"default\":[\"connect\",\"handshake\"],\"handshake\":[\"tungstenite/handshake\"],\"native-tls\":[\"native-tls-crate\",\"tokio-native-tls\",\"stream\",\"tungstenite/native-tls\",\"handshake\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\",\"tungstenite/native-tls-vendored\"],\"proxy\":[\"tungstenite/proxy\",\"tokio/net\",\"handshake\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"stream\":[],\"url\":[\"tungstenite/url\"]},\"strip_prefix\":\"\"}", + "git+https://github.com/JakkuSakura/tungstenite-rs?rev=f514de8644821113e5d18a027d6d28a5c8cc0a6e#f514de8644821113e5d18a027d6d28a5c8cc0a6e_tungstenite": "{\"dependencies\":[{\"name\":\"bytes\"},{\"default_features\":true,\"features\":[],\"name\":\"data-encoding\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"http\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"httparse\",\"optional\":true},{\"name\":\"log\"},{\"default_features\":true,\"features\":[],\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\"},{\"name\":\"rand\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-native-certs\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-pki-types\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"sha1\",\"optional\":true},{\"name\":\"thiserror\"},{\"default_features\":true,\"features\":[],\"name\":\"url\",\"optional\":true},{\"name\":\"utf-8\"},{\"default_features\":true,\"features\":[],\"name\":\"webpki-roots\",\"optional\":true}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\"],\"default\":[\"handshake\"],\"handshake\":[\"data-encoding\",\"http\",\"httparse\",\"sha1\"],\"native-tls\":[\"native-tls-crate\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\"],\"proxy\":[\"handshake\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"url\":[\"dep:url\"]},\"strip_prefix\":\"\"}", "git+https://github.com/nornagon/crossterm?branch=nornagon%2Fcolor-query#87db8bfa6dc99427fd3b071681b07fc31c6ce995_crossterm": "{\"dependencies\":[{\"default_features\":true,\"features\":[],\"name\":\"bitflags\",\"optional\":false},{\"default_features\":false,\"features\":[],\"name\":\"futures-core\",\"optional\":true},{\"name\":\"parking_lot\"},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"filedescriptor\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":false,\"features\":[],\"name\":\"libc\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[\"os-poll\"],\"name\":\"mio\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":false,\"features\":[\"std\",\"stdio\",\"termios\"],\"name\":\"rustix\",\"optional\":false,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[],\"name\":\"signal-hook\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[\"support-v1_0\"],\"name\":\"signal-hook-mio\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[],\"name\":\"crossterm_winapi\",\"optional\":true,\"target\":\"cfg(windows)\"},{\"default_features\":true,\"features\":[\"winuser\",\"winerror\"],\"name\":\"winapi\",\"optional\":true,\"target\":\"cfg(windows)\"}],\"features\":{\"bracketed-paste\":[],\"default\":[\"bracketed-paste\",\"windows\",\"events\"],\"event-stream\":[\"dep:futures-core\",\"events\"],\"events\":[\"dep:mio\",\"dep:signal-hook\",\"dep:signal-hook-mio\"],\"serde\":[\"dep:serde\",\"bitflags/serde\"],\"use-dev-tty\":[\"filedescriptor\",\"rustix/process\"],\"windows\":[\"dep:winapi\",\"dep:crossterm_winapi\"]},\"strip_prefix\":\"\"}", "git+https://github.com/nornagon/ratatui?branch=nornagon-v0.29.0-patch#9b2ad1298408c45918ee9f8241a6f95498cdbed2_ratatui": "{\"dependencies\":[{\"name\":\"bitflags\"},{\"name\":\"cassowary\"},{\"name\":\"compact_str\"},{\"default_features\":true,\"features\":[],\"name\":\"crossterm\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"document-features\",\"optional\":true},{\"name\":\"indoc\"},{\"name\":\"instability\"},{\"name\":\"itertools\"},{\"name\":\"lru\"},{\"default_features\":true,\"features\":[],\"name\":\"palette\",\"optional\":true},{\"name\":\"paste\"},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"strum\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"termwiz\",\"optional\":true},{\"default_features\":true,\"features\":[\"local-offset\"],\"name\":\"time\",\"optional\":true},{\"name\":\"unicode-segmentation\"},{\"name\":\"unicode-truncate\"},{\"name\":\"unicode-width\"},{\"default_features\":true,\"features\":[],\"name\":\"termion\",\"optional\":true,\"target\":\"cfg(not(windows))\"}],\"features\":{\"all-widgets\":[\"widget-calendar\"],\"crossterm\":[\"dep:crossterm\"],\"default\":[\"crossterm\",\"underline-color\"],\"macros\":[],\"palette\":[\"dep:palette\"],\"scrolling-regions\":[],\"serde\":[\"dep:serde\",\"bitflags/serde\",\"compact_str/serde\"],\"termion\":[\"dep:termion\"],\"termwiz\":[\"dep:termwiz\"],\"underline-color\":[\"dep:crossterm\"],\"unstable\":[\"unstable-rendered-line-info\",\"unstable-widget-ref\",\"unstable-backend-writer\"],\"unstable-backend-writer\":[],\"unstable-rendered-line-info\":[],\"unstable-widget-ref\":[],\"widget-calendar\":[\"dep:time\"]},\"strip_prefix\":\"\"}", "globset_0.4.16": "{\"dependencies\":[{\"name\":\"aho-corasick\",\"req\":\"^1.1.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"bstr\",\"req\":\"^1.6.2\"},{\"kind\":\"dev\",\"name\":\"glob\",\"req\":\"^0.3.1\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.20\"},{\"default_features\":false,\"features\":[\"std\",\"perf\",\"syntax\",\"meta\",\"nfa\",\"hybrid\"],\"name\":\"regex-automata\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"regex-syntax\",\"req\":\"^0.8.0\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.188\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.107\"}],\"features\":{\"default\":[\"log\"],\"serde1\":[\"serde\"],\"simd-accel\":[]}}", @@ -633,7 +600,7 @@ "litrs_1.0.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"optional\":true,\"req\":\"^1.0.63\"},{\"name\":\"unicode-xid\",\"optional\":true,\"req\":\"^0.2.4\"}],\"features\":{\"check_suffix\":[\"unicode-xid\"]}}", "local-waker_0.1.4": "{\"dependencies\":[],\"features\":{}}", "lock_api_0.4.13": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"autocfg\",\"req\":\"^1.1.0\"},{\"name\":\"owning_ref\",\"optional\":true,\"req\":\"^0.4.1\"},{\"default_features\":false,\"name\":\"scopeguard\",\"req\":\"^1.1.0\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.126\"}],\"features\":{\"arc_lock\":[],\"atomic_usize\":[],\"default\":[\"atomic_usize\"],\"nightly\":[]}}", - "log_0.4.28": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"proc-macro2\",\"req\":\"^1.0.63\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"sval\",\"optional\":true,\"req\":\"^2.14.1\"},{\"kind\":\"dev\",\"name\":\"sval\",\"req\":\"^2.1\"},{\"kind\":\"dev\",\"name\":\"sval_derive\",\"req\":\"^2.1\"},{\"default_features\":false,\"name\":\"sval_ref\",\"optional\":true,\"req\":\"^2.1\"},{\"default_features\":false,\"features\":[\"inline-i128\"],\"name\":\"value-bag\",\"optional\":true,\"req\":\"^1.7\"},{\"features\":[\"test\"],\"kind\":\"dev\",\"name\":\"value-bag\",\"req\":\"^1.7\"}],\"features\":{\"kv\":[],\"kv_serde\":[\"kv_std\",\"value-bag/serde\",\"serde\"],\"kv_std\":[\"std\",\"kv\",\"value-bag/error\"],\"kv_sval\":[\"kv\",\"value-bag/sval\",\"sval\",\"sval_ref\"],\"kv_unstable\":[\"kv\",\"value-bag\"],\"kv_unstable_serde\":[\"kv_serde\",\"kv_unstable_std\"],\"kv_unstable_std\":[\"kv_std\",\"kv_unstable\"],\"kv_unstable_sval\":[\"kv_sval\",\"kv_unstable\"],\"max_level_debug\":[],\"max_level_error\":[],\"max_level_info\":[],\"max_level_off\":[],\"max_level_trace\":[],\"max_level_warn\":[],\"release_max_level_debug\":[],\"release_max_level_error\":[],\"release_max_level_info\":[],\"release_max_level_off\":[],\"release_max_level_trace\":[],\"release_max_level_warn\":[],\"std\":[]}}", + "log_0.4.29": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"proc-macro2\",\"req\":\"^1.0.63\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"sval\",\"optional\":true,\"req\":\"^2.16\"},{\"kind\":\"dev\",\"name\":\"sval\",\"req\":\"^2.16\"},{\"kind\":\"dev\",\"name\":\"sval_derive\",\"req\":\"^2.16\"},{\"default_features\":false,\"name\":\"sval_ref\",\"optional\":true,\"req\":\"^2.16\"},{\"default_features\":false,\"features\":[\"inline-i128\"],\"name\":\"value-bag\",\"optional\":true,\"req\":\"^1.12\"},{\"features\":[\"test\"],\"kind\":\"dev\",\"name\":\"value-bag\",\"req\":\"^1.12\"}],\"features\":{\"kv\":[],\"kv_serde\":[\"kv_std\",\"value-bag/serde\",\"serde\"],\"kv_std\":[\"std\",\"kv\",\"value-bag/error\"],\"kv_sval\":[\"kv\",\"value-bag/sval\",\"sval\",\"sval_ref\"],\"kv_unstable\":[\"kv\",\"value-bag\"],\"kv_unstable_serde\":[\"kv_serde\",\"kv_unstable_std\"],\"kv_unstable_std\":[\"kv_std\",\"kv_unstable\"],\"kv_unstable_sval\":[\"kv_sval\",\"kv_unstable\"],\"max_level_debug\":[],\"max_level_error\":[],\"max_level_info\":[],\"max_level_off\":[],\"max_level_trace\":[],\"max_level_warn\":[],\"release_max_level_debug\":[],\"release_max_level_error\":[],\"release_max_level_info\":[],\"release_max_level_off\":[],\"release_max_level_trace\":[],\"release_max_level_warn\":[],\"serde\":[\"serde_core\"],\"std\":[]}}", "logos-derive_0.12.1": "{\"dependencies\":[{\"name\":\"beef\",\"req\":\"^0.5.0\"},{\"name\":\"fnv\",\"req\":\"^1.0.6\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^0.6.1\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.9\"},{\"name\":\"quote\",\"req\":\"^1.0.3\"},{\"name\":\"regex-syntax\",\"req\":\"^0.6\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^1.0.17\"}],\"features\":{}}", "logos_0.12.1": "{\"dependencies\":[{\"name\":\"logos-derive\",\"optional\":true,\"req\":\"^0.12.1\"}],\"features\":{\"default\":[\"export_derive\",\"std\"],\"export_derive\":[\"logos-derive\"],\"std\":[]}}", "lru-slab_0.1.2": "{\"dependencies\":[],\"features\":{}}", @@ -907,9 +874,8 @@ "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-tungstenite_0.21.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10.0\"},{\"kind\":\"dev\",\"name\":\"futures-channel\",\"req\":\"^0.3.28\"},{\"default_features\":false,\"features\":[\"sink\",\"std\"],\"name\":\"futures-util\",\"req\":\"^0.3.28\"},{\"default_features\":false,\"features\":[\"http1\",\"server\",\"tcp\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^0.14.25\"},{\"name\":\"log\",\"req\":\"^0.4.17\"},{\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\",\"req\":\"^0.2.11\"},{\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.22.0\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.7.0\"},{\"name\":\"rustls-pki-types\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"io-util\"],\"name\":\"tokio\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"io-std\",\"macros\",\"net\",\"rt-multi-thread\",\"time\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.27.0\"},{\"name\":\"tokio-native-tls\",\"optional\":true,\"req\":\"^0.3.1\"},{\"name\":\"tokio-rustls\",\"optional\":true,\"req\":\"^0.25.0\"},{\"default_features\":false,\"name\":\"tungstenite\",\"req\":\"^0.21.0\"},{\"kind\":\"dev\",\"name\":\"url\",\"req\":\"^2.3.1\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^0.26.0\"}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\",\"tokio-rustls\",\"stream\",\"tungstenite/__rustls-tls\",\"handshake\"],\"connect\":[\"stream\",\"tokio/net\",\"handshake\"],\"default\":[\"connect\",\"handshake\"],\"handshake\":[\"tungstenite/handshake\"],\"native-tls\":[\"native-tls-crate\",\"tokio-native-tls\",\"stream\",\"tungstenite/native-tls\",\"handshake\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\",\"tungstenite/native-tls-vendored\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"stream\":[]}}", "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\":[]}}", + "tokio_1.49.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\"},{\"kind\":\"dev\",\"name\":\"futures-test\",\"req\":\"^0.3.31\"},{\"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\":[]}}", "toml_datetime_0.7.5+spec-1.1.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.225\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.21\"}],\"features\":{\"alloc\":[\"serde_core?/alloc\"],\"default\":[\"std\"],\"serde\":[\"dep:serde_core\"],\"std\":[\"alloc\",\"serde_core?/std\"]}}", @@ -942,7 +908,6 @@ "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\":[]}}", - "tungstenite_0.21.0": "{\"dependencies\":[{\"name\":\"byteorder\",\"req\":\"^1.3.2\"},{\"name\":\"bytes\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\"},{\"name\":\"data-encoding\",\"optional\":true,\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10.0\"},{\"name\":\"http\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"httparse\",\"optional\":true,\"req\":\"^1.3.4\"},{\"kind\":\"dev\",\"name\":\"input_buffer\",\"req\":\"^0.5.0\"},{\"name\":\"log\",\"req\":\"^0.4.8\"},{\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\",\"req\":\"^0.2.3\"},{\"name\":\"rand\",\"req\":\"^0.8.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.4\"},{\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.22.0\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.7.0\"},{\"name\":\"rustls-pki-types\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"sha1\",\"optional\":true,\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"socket2\",\"req\":\"^0.5.5\"},{\"name\":\"thiserror\",\"req\":\"^1.0.23\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2.1.0\"},{\"name\":\"utf-8\",\"req\":\"^0.7.5\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^0.26\"}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\"],\"default\":[\"handshake\"],\"handshake\":[\"data-encoding\",\"http\",\"httparse\",\"sha1\",\"url\"],\"native-tls\":[\"native-tls-crate\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"]}}", "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\":{}}", @@ -993,6 +958,7 @@ "web-time_1.1.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"futures-channel\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_feature = \\\"atomics\\\"))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_feature = \\\"atomics\\\"))\"},{\"features\":[\"js\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.2\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"},{\"name\":\"js-sys\",\"req\":\"^0.3.20\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"features\":[\"macro\"],\"kind\":\"dev\",\"name\":\"pollster\",\"req\":\"^0.3\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"wasm-bindgen\",\"req\":\"^0.2.70\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-futures\",\"req\":\"^0.4\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"},{\"features\":[\"WorkerGlobalScope\"],\"kind\":\"dev\",\"name\":\"web-sys\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_feature = \\\"atomics\\\"))\"},{\"features\":[\"CssStyleDeclaration\",\"Document\",\"Element\",\"HtmlTableElement\",\"HtmlTableRowElement\",\"Performance\",\"Window\"],\"kind\":\"dev\",\"name\":\"web-sys\",\"req\":\"^0.3\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"}],\"features\":{\"serde\":[\"dep:serde\"]}}", "webbrowser_1.0.6": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"actix-files\",\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"actix-web\",\"req\":\"^4\"},{\"name\":\"core-foundation\",\"req\":\"^0.10\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"kind\":\"dev\",\"name\":\"crossbeam-channel\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.9.0\"},{\"name\":\"jni\",\"req\":\"^0.21\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"name\":\"log\",\"req\":\"^0.4\"},{\"name\":\"ndk-context\",\"req\":\"^0.1\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"kind\":\"dev\",\"name\":\"ndk-glue\",\"req\":\">=0.3, <=0.7\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"name\":\"objc2\",\"req\":\"^0.6\",\"target\":\"cfg(any(target_os = \\\"ios\\\", target_os = \\\"tvos\\\", target_os = \\\"visionos\\\"))\"},{\"default_features\":false,\"features\":[\"std\",\"NSDictionary\",\"NSString\",\"NSURL\"],\"name\":\"objc2-foundation\",\"req\":\"^0.3\",\"target\":\"cfg(any(target_os = \\\"ios\\\", target_os = \\\"tvos\\\", target_os = \\\"visionos\\\"))\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^0.10\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"name\":\"url\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"urlencoding\",\"req\":\"^2.1\"},{\"features\":[\"Window\"],\"name\":\"web-sys\",\"req\":\"^0.3\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"}],\"features\":{\"disable-wsl\":[],\"hardened\":[],\"wasm-console\":[\"web-sys/console\"]}}", "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_0.26.11": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"name\":\"parent\",\"package\":\"webpki-roots\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"percent-encoding\",\"req\":\"^2.3\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.13\"},{\"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.102\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.17.0\"},{\"kind\":\"dev\",\"name\":\"yasna\",\"req\":\"^0.5.2\"}],\"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_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\"]}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index b87f76384d..3a81d2b7d6 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -360,16 +360,19 @@ dependencies = [ "objc2-foundation", "parking_lot", "percent-encoding", - "windows-sys 0.52.0", + "windows-sys 0.60.2", "wl-clipboard-rs", "x11rb", ] [[package]] name = "arc-swap" -version = "1.7.1" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" +checksum = "51d03449bb8ca2cc2ef70869af31463d1ae5ccc8fa3e334b307203fbf815207e" +dependencies = [ + "rustversion", +] [[package]] name = "arrayvec" @@ -861,9 +864,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.42" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" dependencies = [ "iana-time-zone", "js-sys", @@ -1298,7 +1301,7 @@ dependencies = [ "codex-windows-sandbox", "core-foundation 0.9.4", "core_test_support", - "ctor 0.5.0", + "ctor 0.6.3", "dirs", "dunce", "encoding_rs", @@ -1681,7 +1684,7 @@ dependencies = [ "anyhow", "clap", "codex-process-hardening", - "ctor 0.5.0", + "ctor 0.6.3", "libc", "reqwest", "serde", @@ -1885,7 +1888,9 @@ dependencies = [ name = "codex-utils-absolute-path" version = "0.0.0" dependencies = [ + "dirs", "path-absolutize", + "pretty_assertions", "schemars 0.8.22", "serde", "serde_json", @@ -2262,9 +2267,9 @@ dependencies = [ [[package]] name = "ctor" -version = "0.5.0" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67773048316103656a637612c4a62477603b777d91d9c62ff2290f9cde178fdb" +checksum = "424e0138278faeb2b401f174ad17e715c829512d74f3d1e81eb43365c2e0590e" dependencies = [ "ctor-proc-macro", "dtor", @@ -2272,9 +2277,9 @@ dependencies = [ [[package]] name = "ctor-proc-macro" -version = "0.0.6" +version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" [[package]] name = "darling" @@ -2829,7 +2834,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -2926,7 +2931,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", "rustix 1.0.8", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3452,7 +3457,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", + "webpki-roots 1.0.2", ] [[package]] @@ -3502,7 +3507,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.1", + "socket2 0.5.10", "system-configuration", "tokio", "tower-service", @@ -3867,7 +3872,7 @@ checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -4160,9 +4165,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.28" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "logos" @@ -5339,7 +5344,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.1", + "socket2 0.5.10", "thiserror 2.0.17", "tokio", "tracing", @@ -5376,9 +5381,9 @@ dependencies = [ "cfg_aliases 0.2.1", "libc", "once_cell", - "socket2 0.6.1", + "socket2 0.5.10", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -5661,7 +5666,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots", + "webpki-roots 1.0.2", ] [[package]] @@ -5757,7 +5762,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5770,7 +5775,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.9.4", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -7071,9 +7076,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.48.0" +version = "1.49.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ "bytes", "libc", @@ -7144,14 +7149,18 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38" +version = "0.28.0" +source = "git+https://github.com/JakkuSakura/tokio-tungstenite?rev=2ae536b0de793f3ddf31fc2f22d445bf1ef2023d#2ae536b0de793f3ddf31fc2f22d445bf1ef2023d" dependencies = [ "futures-util", "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", "tokio", + "tokio-rustls", "tungstenite", + "webpki-roots 0.26.11", ] [[package]] @@ -7550,20 +7559,19 @@ dependencies = [ [[package]] name = "tungstenite" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" +version = "0.28.0" +source = "git+https://github.com/JakkuSakura/tungstenite-rs?rev=f514de8644821113e5d18a027d6d28a5c8cc0a6e#f514de8644821113e5d18a027d6d28a5c8cc0a6e" dependencies = [ - "byteorder", "bytes", "data-encoding", "http 1.3.1", "httparse", "log", - "rand 0.8.5", + "rand 0.9.2", + "rustls", + "rustls-pki-types", "sha1", - "thiserror 1.0.69", - "url", + "thiserror 2.0.17", "utf-8", ] @@ -8034,6 +8042,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.2", +] + [[package]] name = "webpki-roots" version = "1.0.2" @@ -8088,7 +8105,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 33a664e06d..96df92b81e 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -122,12 +122,12 @@ axum = { version = "0.8", default-features = false } base64 = "0.22.1" bytes = "1.10.1" chardetng = "0.1.17" -chrono = "0.4.42" +chrono = "0.4.43" clap = "4" clap_complete = "4" color-eyre = "0.6.3" crossterm = "0.28.1" -ctor = "0.5.0" +ctor = "0.6.3" derive_more = "2" diffy = "0.4.2" dirs = "6" @@ -182,7 +182,7 @@ ratatui-core = "0.1.0" ratatui-macros = "0.6.0" regex = "1.12.2" regex-lite = "0.1.8" -reqwest = "0.12" +reqwest = { version = "0.12", features = ["rustls-tls-webpki-roots", "rustls-tls-native-roots"] } rmcp = { version = "0.12.0", default-features = false } schemars = "0.8.22" seccompiler = "0.5.0" @@ -212,7 +212,7 @@ tiny_http = "0.12" tokio = "1" tokio-stream = "0.1.18" tokio-test = "0.4" -tokio-tungstenite = "0.21.0" +tokio-tungstenite = { version = "0.28.0", features = ["rustls-tls-webpki-roots", "rustls-tls-native-roots", "proxy"] } tokio-util = "0.7.18" toml = "0.9.5" toml_edit = "0.24.0" @@ -303,6 +303,10 @@ opt-level = 0 # ratatui = { path = "../../ratatui" } crossterm = { git = "https://github.com/nornagon/crossterm", branch = "nornagon/color-query" } ratatui = { git = "https://github.com/nornagon/ratatui", branch = "nornagon-v0.29.0-patch" } +tokio-tungstenite = { git = "https://github.com/JakkuSakura/tokio-tungstenite", rev = "2ae536b0de793f3ddf31fc2f22d445bf1ef2023d" } # Uncomment to debug local changes. # rmcp = { path = "../../rust-sdk/crates/rmcp" } + +[patch."ssh://git@github.com/JakkuSakura/tungstenite-rs.git"] +tungstenite = { git = "https://github.com/JakkuSakura/tungstenite-rs", rev = "f514de8644821113e5d18a027d6d28a5c8cc0a6e" } diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index dd54eb25da..911a4a0e9a 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -133,6 +133,10 @@ client_request_definitions! { params: v2::SkillsListParams, response: v2::SkillsListResponse, }, + SkillsConfigWrite => "skills/config/write" { + params: v2::SkillsConfigWriteParams, + response: v2::SkillsConfigWriteResponse, + }, TurnStart => "turn/start" { params: v2::TurnStartParams, response: v2::TurnStartResponse, @@ -150,6 +154,11 @@ client_request_definitions! { params: v2::ModelListParams, response: v2::ModelListResponse, }, + /// EXPERIMENTAL - list collaboration mode presets. + CollaborationModeList => "collaborationMode/list" { + params: v2::CollaborationModeListParams, + response: v2::CollaborationModeListResponse, + }, McpServerOauthLogin => "mcpServer/oauth/login" { params: v2::McpServerOauthLoginParams, @@ -501,6 +510,12 @@ server_request_definitions! { response: v2::FileChangeRequestApprovalResponse, }, + /// EXPERIMENTAL - Request input from the user for a tool call. + ToolRequestUserInput => "item/tool/requestUserInput" { + params: v2::ToolRequestUserInputParams, + response: v2::ToolRequestUserInputResponse, + }, + /// DEPRECATED APIs below /// Request to approve a patch. /// This request is used for Turns started via the legacy APIs (i.e. SendUserTurn, SendUserMessage). @@ -874,4 +889,21 @@ mod tests { ); Ok(()) } + + #[test] + fn serialize_list_collaboration_modes() -> Result<()> { + let request = ClientRequest::CollaborationModeList { + request_id: RequestId::Integer(7), + params: v2::CollaborationModeListParams::default(), + }; + assert_eq!( + json!({ + "method": "collaborationMode/list", + "id": 7, + "params": {} + }), + serde_json::to_value(&request)?, + ); + Ok(()) + } } diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index a5bc69be5d..488862b842 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -128,6 +128,7 @@ pub struct ConversationSummary { pub path: PathBuf, pub preview: String, pub timestamp: Option, + pub updated_at: Option, pub model_provider: String, pub cwd: PathBuf, pub cli_version: String, @@ -502,17 +503,14 @@ impl From for V1TextElement { fn from(value: CoreTextElement) -> Self { Self { byte_range: value.byte_range.into(), - placeholder: value.placeholder, + placeholder: value._placeholder_for_conversion_only().map(str::to_string), } } } impl From for CoreTextElement { fn from(value: V1TextElement) -> Self { - Self { - byte_range: value.byte_range.into(), - placeholder: value.placeholder, - } + Self::new(value.byte_range.into(), value.placeholder) } } diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index ac930e1f42..0635b530e8 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -4,6 +4,7 @@ use std::path::PathBuf; use crate::protocol::common::AuthMode; use codex_protocol::account::PlanType; use codex_protocol::approvals::ExecPolicyAmendment as CoreExecPolicyAmendment; +use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode as CoreSandboxMode; @@ -916,6 +917,20 @@ pub struct ModelListResponse { pub next_cursor: Option, } +/// EXPERIMENTAL - list collaboration mode presets. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CollaborationModeListParams {} + +/// EXPERIMENTAL - collaboration mode presets response. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CollaborationModeListResponse { + pub data: Vec, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -1182,11 +1197,21 @@ pub struct ThreadListParams { pub cursor: Option, /// Optional page size; defaults to a reasonable server-side value. pub limit: Option, + /// Optional sort key; defaults to created_at. + pub sort_key: Option, /// Optional provider filter; when set, only sessions recorded under these /// providers are returned. When present but empty, includes all providers. pub model_providers: Option>, } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub enum ThreadSortKey { + CreatedAt, + UpdatedAt, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -1264,6 +1289,7 @@ pub struct SkillMetadata { pub interface: Option, pub path: PathBuf, pub scope: SkillScope, + pub enabled: bool, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -1301,6 +1327,21 @@ pub struct SkillsListEntry { pub errors: Vec, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillsConfigWriteParams { + pub path: PathBuf, + pub enabled: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillsConfigWriteResponse { + pub effective_enabled: bool, +} + impl From for SkillMetadata { fn from(value: CoreSkillMetadata) -> Self { Self { @@ -1310,6 +1351,7 @@ impl From for SkillMetadata { interface: value.interface.map(SkillInterface::from), path: value.path, scope: value.scope.into(), + enabled: true, } } } @@ -1359,6 +1401,9 @@ pub struct Thread { /// Unix timestamp (in seconds) when the thread was created. #[ts(type = "number")] pub created_at: i64, + /// Unix timestamp (in seconds) when the thread was last updated. + #[ts(type = "number")] + pub updated_at: i64, /// [UNSTABLE] Path to the thread on disk. pub path: PathBuf, /// Working directory captured for the thread. @@ -1508,6 +1553,10 @@ pub struct TurnStartParams { pub summary: Option, /// Optional JSON Schema used to constrain the final assistant message for this turn. pub output_schema: Option, + + /// EXPERIMENTAL - set a pre-set collaboration mode. + /// Takes precedence over model, reasoning_effort, and developer instructions if set. + pub collaboration_mode: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -1616,24 +1665,38 @@ pub struct TextElement { /// Byte range in the parent `text` buffer that this element occupies. pub byte_range: ByteRange, /// Optional human-readable placeholder for the element, displayed in the UI. - pub placeholder: Option, + placeholder: Option, +} + +impl TextElement { + pub fn new(byte_range: ByteRange, placeholder: Option) -> Self { + Self { + byte_range, + placeholder, + } + } + + pub fn set_placeholder(&mut self, placeholder: Option) { + self.placeholder = placeholder; + } + + pub fn placeholder(&self) -> Option<&str> { + self.placeholder.as_deref() + } } impl From for TextElement { fn from(value: CoreTextElement) -> Self { - Self { - byte_range: value.byte_range.into(), - placeholder: value.placeholder, - } + Self::new( + value.byte_range.into(), + value._placeholder_for_conversion_only().map(str::to_string), + ) } } impl From for CoreTextElement { fn from(value: TextElement) -> Self { - Self { - byte_range: value.byte_range.into(), - placeholder: value.placeholder, - } + Self::new(value.byte_range.into(), value.placeholder) } } @@ -1771,12 +1834,12 @@ pub enum ThreadItem { /// Thread ID of the agent issuing the collab request. sender_thread_id: String, /// Thread ID of the receiving agent, when applicable. In case of spawn operation, - /// this correspond to the newly spawned agent. - receiver_thread_id: Option, + /// this corresponds to the newly spawned agent. + receiver_thread_ids: Vec, /// Prompt text sent as part of the collab tool call, when available. prompt: Option, - /// Last known status of the target agent, when available. - agent_state: Option, + /// Last known status of the target agents, when available. + agents_states: HashMap, }, #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] @@ -2228,6 +2291,54 @@ pub struct FileChangeRequestApprovalResponse { pub decision: FileChangeApprovalDecision, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL. Defines a single selectable option for request_user_input. +pub struct ToolRequestUserInputOption { + pub label: String, + pub description: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL. Represents one request_user_input question and its optional options. +pub struct ToolRequestUserInputQuestion { + pub id: String, + pub header: String, + pub question: String, + pub options: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL. Params sent with a request_user_input event. +pub struct ToolRequestUserInputParams { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub questions: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL. Captures a user's answer to a request_user_input question. +pub struct ToolRequestUserInputAnswer { + pub selected: Vec, + pub other: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL. Response payload mapping question ids to answers. +pub struct ToolRequestUserInputResponse { + pub answers: HashMap, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server-test-client/src/main.rs b/codex-rs/app-server-test-client/src/main.rs index 9df895a70a..1407c2401d 100644 --- a/codex-rs/app-server-test-client/src/main.rs +++ b/codex-rs/app-server-test-client/src/main.rs @@ -258,7 +258,7 @@ fn send_message_v2_with_policies( thread_id: thread_response.thread.id.clone(), input: vec![V2UserInput::Text { text: user_message, - // Plain text conversion has no UI element ranges. + // Test client sends plain text without UI element ranges. text_elements: Vec::new(), }], ..Default::default() @@ -292,6 +292,7 @@ fn send_follow_up_v2( thread_id: thread_response.thread.id.clone(), input: vec![V2UserInput::Text { text: first_message, + // Test client sends plain text without UI element ranges. text_elements: Vec::new(), }], ..Default::default() @@ -304,6 +305,7 @@ fn send_follow_up_v2( thread_id: thread_response.thread.id.clone(), input: vec![V2UserInput::Text { text: follow_up_message, + // Test client sends plain text without UI element ranges. text_elements: Vec::new(), }], ..Default::default() @@ -477,6 +479,7 @@ impl CodexClient { conversation_id: *conversation_id, items: vec![InputItem::Text { text: message.to_string(), + // Test client sends plain text without UI element ranges. text_elements: Vec::new(), }], }, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 63f4dea965..9bc8c3dff1 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -86,8 +86,11 @@ Example (from OpenAI's official VSCode extension): - `review/start` — kick off Codex’s automated reviewer for a thread; responds like `turn/start` and emits `item/started`/`item/completed` notifications with `enteredReviewMode` and `exitedReviewMode` items, plus a final assistant `agentMessage` containing the review. - `command/exec` — run a single command under the server sandbox without starting a thread/turn (handy for utilities and validation). - `model/list` — list available models (with reasoning effort options). +- `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). - `skills/list` — list skills for one or more `cwd` values (optional `forceReload`). +- `skills/config/write` — write user-level skill config by path. - `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes. +- `tool/requestUserInput` — prompt the user with 1–3 short questions for a tool call and return their answers (experimental). - `config/mcpServer/reload` — reload MCP server config from disk and queue a refresh for loaded threads (applied on each thread's next active turn); returns `{}`. Use this after editing `config.toml` without restarting the server. - `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. @@ -138,10 +141,11 @@ To branch from a stored session, call `thread/fork` with the `thread.id`. This c ### Example: List threads (with pagination & filters) -`thread/list` lets you render a history UI. Pass any combination of: +`thread/list` lets you render a history UI. Results default to `createdAt` (newest first) descending. Pass any combination of: - `cursor` — opaque string from a prior response; omit for the first page. - `limit` — server defaults to a reasonable page size if unset. +- `sortKey` — `created_at` (default) or `updated_at`. - `modelProviders` — restrict results to specific providers; unset, null, or an empty array will include all providers. Example: @@ -150,11 +154,12 @@ Example: { "method": "thread/list", "id": 20, "params": { "cursor": null, "limit": 25, + "sortKey": "created_at" } } { "id": 20, "result": { "data": [ - { "id": "thr_a", "preview": "Create a TUI", "modelProvider": "openai", "createdAt": 1730831111 }, - { "id": "thr_b", "preview": "Fix tests", "modelProvider": "openai", "createdAt": 1730750000 } + { "id": "thr_a", "preview": "Create a TUI", "modelProvider": "openai", "createdAt": 1730831111, "updatedAt": 1730831111 }, + { "id": "thr_b", "preview": "Fix tests", "modelProvider": "openai", "createdAt": 1730750000, "updatedAt": 1730750000 } ], "nextCursor": "opaque-token-or-null" } } @@ -466,8 +471,15 @@ Invoke a skill by including `$` in the text input. Add a `skill` inp "params": { "threadId": "thread-1", "input": [ - { "type": "text", "text": "$skill-creator Add a new skill for triaging flaky CI." }, - { "type": "skill", "name": "skill-creator", "path": "/Users/me/.codex/skills/skill-creator/SKILL.md" } + { + "type": "text", + "text": "$skill-creator Add a new skill for triaging flaky CI." + }, + { + "type": "skill", + "name": "skill-creator", + "path": "/Users/me/.codex/skills/skill-creator/SKILL.md" + } ] } } @@ -481,20 +493,37 @@ Example: $skill-creator Add a new skill for triaging flaky CI and include step-by-step usage. ``` -Use `skills/list` to fetch the available skills (optionally scoped by `cwd` and/or with `forceReload`). +Use `skills/list` to fetch the available skills (optionally scoped by `cwds`, with `forceReload`). ```json { "method": "skills/list", "id": 25, "params": { - "cwd": "/Users/me/project", + "cwds": ["/Users/me/project"], "forceReload": false } } { "id": 25, "result": { - "skills": [ - { "name": "skill-creator", "description": "Create or update a Codex skill" } - ] + "data": [{ + "cwd": "/Users/me/project", + "skills": [ + { "name": "skill-creator", "description": "Create or update a Codex skill", "enabled": true } + ], + "errors": [] + }] } } ``` +To enable or disable a skill by path: + +```json +{ + "method": "skills/config/write", + "id": 26, + "params": { + "path": "/Users/me/.codex/skills/skill-creator/SKILL.md", + "enabled": false + } +} +``` + ## Auth endpoints The JSON-RPC auth/account surface exposes request/response methods plus server-initiated notifications (no `id`). Use these to determine auth state, start or cancel logins, logout, and inspect ChatGPT rate limits. diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index f14dd2ddca..225b2aface 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -54,6 +54,10 @@ use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadRollbackResponse; use codex_app_server_protocol::ThreadTokenUsage; use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; +use codex_app_server_protocol::ToolRequestUserInputOption; +use codex_app_server_protocol::ToolRequestUserInputParams; +use codex_app_server_protocol::ToolRequestUserInputQuestion; +use codex_app_server_protocol::ToolRequestUserInputResponse; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnDiffUpdatedNotification; @@ -83,6 +87,8 @@ use codex_core::review_prompts; use codex_protocol::ThreadId; use codex_protocol::plan_tool::UpdatePlanArgs; use codex_protocol::protocol::ReviewOutputEvent; +use codex_protocol::request_user_input::RequestUserInputAnswer as CoreRequestUserInputAnswer; +use codex_protocol::request_user_input::RequestUserInputResponse as CoreRequestUserInputResponse; use std::collections::HashMap; use std::convert::TryFrom; use std::path::PathBuf; @@ -258,6 +264,57 @@ pub(crate) async fn apply_bespoke_event_handling( }); } }, + EventMsg::RequestUserInput(request) => { + if matches!(api_version, ApiVersion::V2) { + let questions = request + .questions + .into_iter() + .map(|question| ToolRequestUserInputQuestion { + id: question.id, + header: question.header, + question: question.question, + options: question.options.map(|options| { + options + .into_iter() + .map(|option| ToolRequestUserInputOption { + label: option.label, + description: option.description, + }) + .collect() + }), + }) + .collect(); + let params = ToolRequestUserInputParams { + thread_id: conversation_id.to_string(), + turn_id: request.turn_id, + item_id: request.call_id, + questions, + }; + let rx = outgoing + .send_request(ServerRequestPayload::ToolRequestUserInput(params)) + .await; + tokio::spawn(async move { + on_request_user_input_response(event_turn_id, rx, conversation).await; + }); + } else { + error!( + "request_user_input is only supported on api v2 (call_id: {})", + request.call_id + ); + let empty = CoreRequestUserInputResponse { + answers: HashMap::new(), + }; + if let Err(err) = conversation + .submit(Op::UserInputAnswer { + id: event_turn_id, + response: empty, + }) + .await + { + error!("failed to submit UserInputAnswer: {err}"); + } + } + } // TODO(celia): properly construct McpToolCall TurnItem in core. EventMsg::McpToolCallBegin(begin_event) => { let notification = construct_mcp_tool_call_notification( @@ -287,9 +344,9 @@ pub(crate) async fn apply_bespoke_event_handling( tool: CollabAgentTool::SpawnAgent, status: V2CollabToolCallStatus::InProgress, sender_thread_id: begin_event.sender_thread_id.to_string(), - receiver_thread_id: None, + receiver_thread_ids: Vec::new(), prompt: Some(begin_event.prompt), - agent_state: None, + agents_states: HashMap::new(), }; let notification = ItemStartedNotification { thread_id: conversation_id.to_string(), @@ -301,19 +358,32 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::CollabAgentSpawnEnd(end_event) => { - let status = if end_event.new_thread_id.is_some() { - V2CollabToolCallStatus::Completed - } else { - V2CollabToolCallStatus::Failed + let has_receiver = end_event.new_thread_id.is_some(); + let status = match &end_event.status { + codex_protocol::protocol::AgentStatus::Errored(_) + | codex_protocol::protocol::AgentStatus::NotFound => V2CollabToolCallStatus::Failed, + _ if has_receiver => V2CollabToolCallStatus::Completed, + _ => V2CollabToolCallStatus::Failed, + }; + let (receiver_thread_ids, agents_states) = match end_event.new_thread_id { + Some(id) => { + let receiver_id = id.to_string(); + let received_status = V2CollabAgentStatus::from(end_event.status.clone()); + ( + vec![receiver_id.clone()], + [(receiver_id, received_status)].into_iter().collect(), + ) + } + None => (Vec::new(), HashMap::new()), }; let item = ThreadItem::CollabAgentToolCall { id: end_event.call_id, tool: CollabAgentTool::SpawnAgent, status, sender_thread_id: end_event.sender_thread_id.to_string(), - receiver_thread_id: end_event.new_thread_id.map(|id| id.to_string()), + receiver_thread_ids, prompt: Some(end_event.prompt), - agent_state: Some(V2CollabAgentStatus::from(end_event.status)), + agents_states, }; let notification = ItemCompletedNotification { thread_id: conversation_id.to_string(), @@ -325,14 +395,15 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::CollabAgentInteractionBegin(begin_event) => { + let receiver_thread_ids = vec![begin_event.receiver_thread_id.to_string()]; let item = ThreadItem::CollabAgentToolCall { id: begin_event.call_id, tool: CollabAgentTool::SendInput, status: V2CollabToolCallStatus::InProgress, sender_thread_id: begin_event.sender_thread_id.to_string(), - receiver_thread_id: Some(begin_event.receiver_thread_id.to_string()), + receiver_thread_ids, prompt: Some(begin_event.prompt), - agent_state: None, + agents_states: HashMap::new(), }; let notification = ItemStartedNotification { thread_id: conversation_id.to_string(), @@ -344,19 +415,21 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::CollabAgentInteractionEnd(end_event) => { - let status = match end_event.status { + let status = match &end_event.status { codex_protocol::protocol::AgentStatus::Errored(_) | codex_protocol::protocol::AgentStatus::NotFound => V2CollabToolCallStatus::Failed, _ => V2CollabToolCallStatus::Completed, }; + let receiver_id = end_event.receiver_thread_id.to_string(); + let received_status = V2CollabAgentStatus::from(end_event.status); let item = ThreadItem::CollabAgentToolCall { id: end_event.call_id, tool: CollabAgentTool::SendInput, status, sender_thread_id: end_event.sender_thread_id.to_string(), - receiver_thread_id: Some(end_event.receiver_thread_id.to_string()), + receiver_thread_ids: vec![receiver_id.clone()], prompt: Some(end_event.prompt), - agent_state: Some(V2CollabAgentStatus::from(end_event.status)), + agents_states: [(receiver_id, received_status)].into_iter().collect(), }; let notification = ItemCompletedNotification { thread_id: conversation_id.to_string(), @@ -368,14 +441,19 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::CollabWaitingBegin(begin_event) => { + let receiver_thread_ids = begin_event + .receiver_thread_ids + .iter() + .map(ToString::to_string) + .collect(); let item = ThreadItem::CollabAgentToolCall { id: begin_event.call_id, tool: CollabAgentTool::Wait, status: V2CollabToolCallStatus::InProgress, sender_thread_id: begin_event.sender_thread_id.to_string(), - receiver_thread_id: Some(begin_event.receiver_thread_id.to_string()), + receiver_thread_ids, prompt: None, - agent_state: None, + agents_states: HashMap::new(), }; let notification = ItemStartedNotification { thread_id: conversation_id.to_string(), @@ -387,19 +465,31 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::CollabWaitingEnd(end_event) => { - let status = match end_event.status { - codex_protocol::protocol::AgentStatus::Errored(_) - | codex_protocol::protocol::AgentStatus::NotFound => V2CollabToolCallStatus::Failed, - _ => V2CollabToolCallStatus::Completed, + let status = if end_event.statuses.values().any(|status| { + matches!( + status, + codex_protocol::protocol::AgentStatus::Errored(_) + | codex_protocol::protocol::AgentStatus::NotFound + ) + }) { + V2CollabToolCallStatus::Failed + } else { + V2CollabToolCallStatus::Completed }; + let receiver_thread_ids = end_event.statuses.keys().map(ToString::to_string).collect(); + let agents_states = end_event + .statuses + .iter() + .map(|(id, status)| (id.to_string(), V2CollabAgentStatus::from(status.clone()))) + .collect(); let item = ThreadItem::CollabAgentToolCall { id: end_event.call_id, tool: CollabAgentTool::Wait, status, sender_thread_id: end_event.sender_thread_id.to_string(), - receiver_thread_id: Some(end_event.receiver_thread_id.to_string()), + receiver_thread_ids, prompt: None, - agent_state: Some(V2CollabAgentStatus::from(end_event.status)), + agents_states, }; let notification = ItemCompletedNotification { thread_id: conversation_id.to_string(), @@ -416,9 +506,9 @@ pub(crate) async fn apply_bespoke_event_handling( tool: CollabAgentTool::CloseAgent, status: V2CollabToolCallStatus::InProgress, sender_thread_id: begin_event.sender_thread_id.to_string(), - receiver_thread_id: Some(begin_event.receiver_thread_id.to_string()), + receiver_thread_ids: vec![begin_event.receiver_thread_id.to_string()], prompt: None, - agent_state: None, + agents_states: HashMap::new(), }; let notification = ItemStartedNotification { thread_id: conversation_id.to_string(), @@ -430,19 +520,26 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::CollabCloseEnd(end_event) => { - let status = match end_event.status { + let status = match &end_event.status { codex_protocol::protocol::AgentStatus::Errored(_) | codex_protocol::protocol::AgentStatus::NotFound => V2CollabToolCallStatus::Failed, _ => V2CollabToolCallStatus::Completed, }; + let receiver_id = end_event.receiver_thread_id.to_string(); + let agents_states = [( + receiver_id.clone(), + V2CollabAgentStatus::from(end_event.status), + )] + .into_iter() + .collect(); let item = ThreadItem::CollabAgentToolCall { id: end_event.call_id, tool: CollabAgentTool::CloseAgent, status, sender_thread_id: end_event.sender_thread_id.to_string(), - receiver_thread_id: Some(end_event.receiver_thread_id.to_string()), + receiver_thread_ids: vec![receiver_id], prompt: None, - agent_state: Some(V2CollabAgentStatus::from(end_event.status)), + agents_states, }; let notification = ItemCompletedNotification { thread_id: conversation_id.to_string(), @@ -1307,6 +1404,66 @@ async fn on_exec_approval_response( } } +async fn on_request_user_input_response( + event_turn_id: String, + receiver: oneshot::Receiver, + conversation: Arc, +) { + let response = receiver.await; + let value = match response { + Ok(value) => value, + Err(err) => { + error!("request failed: {err:?}"); + let empty = CoreRequestUserInputResponse { + answers: HashMap::new(), + }; + if let Err(err) = conversation + .submit(Op::UserInputAnswer { + id: event_turn_id, + response: empty, + }) + .await + { + error!("failed to submit UserInputAnswer: {err}"); + } + return; + } + }; + + let response = + serde_json::from_value::(value).unwrap_or_else(|err| { + error!("failed to deserialize ToolRequestUserInputResponse: {err}"); + ToolRequestUserInputResponse { + answers: HashMap::new(), + } + }); + let response = CoreRequestUserInputResponse { + answers: response + .answers + .into_iter() + .map(|(id, answer)| { + ( + id, + CoreRequestUserInputAnswer { + selected: answer.selected, + other: answer.other, + }, + ) + }) + .collect(), + }; + + if let Err(err) = conversation + .submit(Op::UserInputAnswer { + id: event_turn_id, + response, + }) + .await + { + error!("failed to submit UserInputAnswer: {err}"); + } +} + const REVIEW_FALLBACK_MESSAGE: &str = "Reviewer failed to output a response."; fn render_review_output_text(output: &ReviewOutputEvent) -> String { diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index fc7115447d..a83607a621 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6,6 +6,7 @@ use crate::models::supported_models; use crate::outgoing_message::OutgoingMessageSender; use crate::outgoing_message::OutgoingNotification; use chrono::DateTime; +use chrono::SecondsFormat; use chrono::Utc; use codex_app_server_protocol::Account; use codex_app_server_protocol::AccountLoginCompletedNotification; @@ -22,6 +23,8 @@ use codex_app_server_protocol::CancelLoginAccountResponse; use codex_app_server_protocol::CancelLoginAccountStatus; use codex_app_server_protocol::CancelLoginChatGptResponse; use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::CollaborationModeListParams; +use codex_app_server_protocol::CollaborationModeListResponse; use codex_app_server_protocol::CommandExecParams; use codex_app_server_protocol::ConversationGitInfo; use codex_app_server_protocol::ConversationSummary; @@ -84,6 +87,8 @@ use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionConfiguredNotification; use codex_app_server_protocol::SetDefaultModelParams; use codex_app_server_protocol::SetDefaultModelResponse; +use codex_app_server_protocol::SkillsConfigWriteParams; +use codex_app_server_protocol::SkillsConfigWriteResponse; use codex_app_server_protocol::SkillsListParams; use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::Thread; @@ -99,6 +104,7 @@ use codex_app_server_protocol::ThreadLoadedListResponse; use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadRollbackParams; +use codex_app_server_protocol::ThreadSortKey; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStartedNotification; @@ -123,11 +129,13 @@ use codex_core::NewThread; use codex_core::RolloutRecorder; use codex_core::SessionMeta; use codex_core::ThreadManager; +use codex_core::ThreadSortKey as CoreThreadSortKey; use codex_core::auth::CLIENT_ID; use codex_core::auth::login_with_api_key; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::ConfigService; +use codex_core::config::edit::ConfigEdit; use codex_core::config::edit::ConfigEditsBuilder; use codex_core::config::types::McpServerTransportConfig; use codex_core::default_client::get_codex_user_agent; @@ -397,6 +405,9 @@ impl CodexMessageProcessor { ClientRequest::SkillsList { request_id, params } => { self.skills_list(request_id, params).await; } + ClientRequest::SkillsConfigWrite { request_id, params } => { + self.skills_config_write(request_id, params).await; + } ClientRequest::TurnStart { request_id, params } => { self.turn_start(request_id, params).await; } @@ -427,6 +438,15 @@ impl CodexMessageProcessor { Self::list_models(outgoing, thread_manager, config, request_id, params).await; }); } + ClientRequest::CollaborationModeList { request_id, params } => { + let outgoing = self.outgoing.clone(); + let thread_manager = self.thread_manager.clone(); + + tokio::spawn(async move { + Self::list_collaboration_modes(outgoing, thread_manager, request_id, params) + .await; + }); + } ClientRequest::McpServerOauthLogin { request_id, params } => { self.mcp_server_oauth_login(request_id, params).await; } @@ -1598,6 +1618,7 @@ impl CodexMessageProcessor { let ThreadListParams { cursor, limit, + sort_key, model_providers, } = params; @@ -1605,8 +1626,12 @@ impl CodexMessageProcessor { .map(|value| value as usize) .unwrap_or(THREAD_LIST_DEFAULT_LIMIT) .clamp(1, THREAD_LIST_MAX_LIMIT); + let core_sort_key = match sort_key.unwrap_or(ThreadSortKey::CreatedAt) { + ThreadSortKey::CreatedAt => CoreThreadSortKey::CreatedAt, + ThreadSortKey::UpdatedAt => CoreThreadSortKey::UpdatedAt, + }; let (summaries, next_cursor) = match self - .list_threads_common(requested_page_size, cursor, model_providers) + .list_threads_common(requested_page_size, cursor, model_providers, core_sort_key) .await { Ok(r) => r, @@ -2171,7 +2196,12 @@ impl CodexMessageProcessor { .clamp(1, THREAD_LIST_MAX_LIMIT); match self - .list_threads_common(requested_page_size, cursor, model_providers) + .list_threads_common( + requested_page_size, + cursor, + model_providers, + CoreThreadSortKey::UpdatedAt, + ) .await { Ok((items, next_cursor)) => { @@ -2189,8 +2219,18 @@ impl CodexMessageProcessor { requested_page_size: usize, cursor: Option, model_providers: Option>, + sort_key: CoreThreadSortKey, ) -> Result<(Vec, Option), JSONRPCErrorError> { - let mut cursor_obj: Option = cursor.as_ref().and_then(|s| parse_cursor(s)); + let mut cursor_obj: Option = match cursor.as_ref() { + Some(cursor_str) => { + Some(parse_cursor(cursor_str).ok_or_else(|| JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("invalid cursor: {cursor_str}"), + data: None, + })?) + } + None => None, + }; let mut last_cursor = cursor_obj.clone(); let mut remaining = requested_page_size; let mut items = Vec::with_capacity(requested_page_size); @@ -2214,6 +2254,7 @@ impl CodexMessageProcessor { &self.config.codex_home, page_size, cursor_obj.as_ref(), + sort_key, INTERACTIVE_SESSION_SOURCES, model_provider_filter.as_deref(), fallback_provider.as_str(), @@ -2229,6 +2270,7 @@ impl CodexMessageProcessor { .items .into_iter() .filter_map(|it| { + let updated_at = it.updated_at.clone(); let session_meta_line = it.head.first().and_then(|first| { serde_json::from_value::(first.clone()).ok() })?; @@ -2238,6 +2280,7 @@ impl CodexMessageProcessor { &session_meta_line.meta, session_meta_line.git.as_ref(), fallback_provider.as_str(), + updated_at, ) }) .collect::>(); @@ -2339,6 +2382,18 @@ impl CodexMessageProcessor { outgoing.send_response(request_id, response).await; } + async fn list_collaboration_modes( + outgoing: Arc, + thread_manager: Arc, + request_id: RequestId, + params: CollaborationModeListParams, + ) { + let CollaborationModeListParams {} = params; + let items = thread_manager.list_collaboration_modes(); + let response = CollaborationModeListResponse { data: items }; + 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, @@ -3199,6 +3254,7 @@ impl CodexMessageProcessor { effort, summary, final_output_json_schema: output_schema, + collaboration_mode: None, }) .await; @@ -3220,7 +3276,7 @@ impl CodexMessageProcessor { for cwd in cwds { let outcome = skills_manager.skills_for_cwd(&cwd, force_reload).await; let errors = errors_to_info(&outcome.errors); - let skills = skills_to_info(&outcome.skills); + let skills = skills_to_info(&outcome.skills, &outcome.disabled_paths); data.push(codex_app_server_protocol::SkillsListEntry { cwd, skills, @@ -3232,6 +3288,37 @@ impl CodexMessageProcessor { .await; } + async fn skills_config_write(&self, request_id: RequestId, params: SkillsConfigWriteParams) { + let SkillsConfigWriteParams { path, enabled } = params; + let edits = vec![ConfigEdit::SetSkillConfig { path, enabled }]; + let result = ConfigEditsBuilder::new(&self.config.codex_home) + .with_edits(edits) + .apply() + .await; + + match result { + Ok(()) => { + self.thread_manager.skills_manager().clear_cache(); + self.outgoing + .send_response( + request_id, + SkillsConfigWriteResponse { + effective_enabled: enabled, + }, + ) + .await; + } + Err(err) => { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to update skill settings: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + } + } + } + async fn interrupt_conversation( &mut self, request_id: RequestId, @@ -3281,7 +3368,8 @@ impl CodexMessageProcessor { || params.sandbox_policy.is_some() || params.model.is_some() || params.effort.is_some() - || params.summary.is_some(); + || params.summary.is_some() + || params.collaboration_mode.is_some(); // If any overrides are provided, update the session turn context first. if has_any_overrides { @@ -3293,6 +3381,7 @@ impl CodexMessageProcessor { model: params.model, effort: params.effort.map(Some), summary: params.summary, + collaboration_mode: params.collaboration_mode, }) .await; } @@ -3892,25 +3981,30 @@ impl CodexMessageProcessor { fn skills_to_info( skills: &[codex_core::skills::SkillMetadata], + disabled_paths: &std::collections::HashSet, ) -> Vec { skills .iter() - .map(|skill| codex_app_server_protocol::SkillMetadata { - name: skill.name.clone(), - description: skill.description.clone(), - short_description: skill.short_description.clone(), - interface: skill.interface.clone().map(|interface| { - codex_app_server_protocol::SkillInterface { - display_name: interface.display_name, - short_description: interface.short_description, - icon_small: interface.icon_small, - icon_large: interface.icon_large, - brand_color: interface.brand_color, - default_prompt: interface.default_prompt, - } - }), - path: skill.path.clone(), - scope: skill.scope.into(), + .map(|skill| { + let enabled = !disabled_paths.contains(&skill.path); + codex_app_server_protocol::SkillMetadata { + name: skill.name.clone(), + description: skill.description.clone(), + short_description: skill.short_description.clone(), + interface: skill.interface.clone().map(|interface| { + codex_app_server_protocol::SkillInterface { + display_name: interface.display_name, + short_description: interface.short_description, + icon_small: interface.icon_small, + icon_large: interface.icon_large, + brand_color: interface.brand_color, + default_prompt: interface.default_prompt, + } + }), + path: skill.path.clone(), + scope: skill.scope.into(), + enabled, + } }) .collect() } @@ -3982,12 +4076,19 @@ pub(crate) async fn read_summary_from_rollout( git, } = session_meta_line; + let created_at = if session_meta.timestamp.is_empty() { + None + } else { + Some(session_meta.timestamp.as_str()) + }; + let updated_at = read_updated_at(path, created_at).await; if let Some(summary) = extract_conversation_summary( path.to_path_buf(), &head, &session_meta, git.as_ref(), fallback_provider, + updated_at.clone(), ) { return Ok(summary); } @@ -4002,10 +4103,12 @@ pub(crate) async fn read_summary_from_rollout( .clone() .unwrap_or_else(|| fallback_provider.to_string()); let git_info = git.as_ref().map(map_git_info); + let updated_at = updated_at.or_else(|| timestamp.clone()); Ok(ConversationSummary { conversation_id: session_meta.id, timestamp, + updated_at, path: path.to_path_buf(), preview: String::new(), model_provider, @@ -4040,6 +4143,7 @@ fn extract_conversation_summary( session_meta: &SessionMeta, git: Option<&CoreGitInfo>, fallback_provider: &str, + updated_at: Option, ) -> Option { let preview = head .iter() @@ -4065,10 +4169,12 @@ fn extract_conversation_summary( .clone() .unwrap_or_else(|| fallback_provider.to_string()); let git_info = git.map(map_git_info); + let updated_at = updated_at.or_else(|| timestamp.clone()); Some(ConversationSummary { conversation_id, timestamp, + updated_at, path, preview: preview.to_string(), model_provider, @@ -4095,12 +4201,25 @@ fn parse_datetime(timestamp: Option<&str>) -> Option> { }) } +async fn read_updated_at(path: &Path, created_at: Option<&str>) -> Option { + let updated_at = tokio::fs::metadata(path) + .await + .ok() + .and_then(|meta| meta.modified().ok()) + .map(|modified| { + let updated_at: DateTime = modified.into(); + updated_at.to_rfc3339_opts(SecondsFormat::Secs, true) + }); + updated_at.or_else(|| created_at.map(str::to_string)) +} + pub(crate) fn summary_to_thread(summary: ConversationSummary) -> Thread { let ConversationSummary { conversation_id, path, preview, timestamp, + updated_at, model_provider, cwd, cli_version, @@ -4109,6 +4228,7 @@ pub(crate) fn summary_to_thread(summary: ConversationSummary) -> Thread { } = summary; let created_at = parse_datetime(timestamp.as_deref()); + let updated_at = parse_datetime(updated_at.as_deref()).or(created_at); let git_info = git_info.map(|info| ApiGitInfo { sha: info.sha, branch: info.branch, @@ -4120,6 +4240,7 @@ pub(crate) fn summary_to_thread(summary: ConversationSummary) -> Thread { preview, model_provider, created_at: created_at.map(|dt| dt.timestamp()).unwrap_or(0), + updated_at: updated_at.map(|dt| dt.timestamp()).unwrap_or(0), path, cwd, cli_version, @@ -4151,7 +4272,6 @@ mod tests { "cwd": "/", "originator": "codex", "cli_version": "0.0.0", - "instructions": null, "model_provider": "test-provider" }), json!({ @@ -4174,13 +4294,20 @@ mod tests { let session_meta = serde_json::from_value::(head[0].clone())?; - let summary = - extract_conversation_summary(path.clone(), &head, &session_meta, None, "test-provider") - .expect("summary"); + let summary = extract_conversation_summary( + path.clone(), + &head, + &session_meta, + None, + "test-provider", + timestamp.clone(), + ) + .expect("summary"); let expected = ConversationSummary { conversation_id, - timestamp, + timestamp: timestamp.clone(), + updated_at: timestamp, path, preview: "Count to 5".to_string(), model_provider: "test-provider".to_string(), @@ -4200,6 +4327,7 @@ mod tests { use codex_protocol::protocol::RolloutLine; use codex_protocol::protocol::SessionMetaLine; use std::fs; + use std::fs::FileTimes; let temp_dir = TempDir::new()?; let path = temp_dir.path().join("rollout.jsonl"); @@ -4223,12 +4351,19 @@ mod tests { }; fs::write(&path, format!("{}\n", serde_json::to_string(&line)?))?; + let parsed = chrono::DateTime::parse_from_rfc3339(×tamp)?.with_timezone(&Utc); + let times = FileTimes::new().set_modified(parsed.into()); + std::fs::OpenOptions::new() + .append(true) + .open(&path)? + .set_times(times)?; let summary = read_summary_from_rollout(path.as_path(), "fallback").await?; let expected = ConversationSummary { conversation_id, - timestamp: Some(timestamp), + timestamp: Some(timestamp.clone()), + updated_at: Some("2025-09-05T16:53:11Z".to_string()), path: path.clone(), preview: String::new(), model_provider: "fallback".to_string(), diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 48577db110..8093121403 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -27,9 +27,11 @@ pub use models_cache::write_models_cache_with_models; pub use responses::create_apply_patch_sse_response; pub use responses::create_exec_command_sse_response; pub use responses::create_final_assistant_message_sse_response; +pub use responses::create_request_user_input_sse_response; pub use responses::create_shell_command_sse_response; pub use rollout::create_fake_rollout; pub use rollout::create_fake_rollout_with_text_elements; +pub use rollout::rollout_path; use serde::de::DeserializeOwned; pub fn to_response(response: JSONRPCResponse) -> anyhow::Result { diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index cde6ff3913..fe236a52b2 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -17,6 +17,7 @@ use codex_app_server_protocol::CancelLoginAccountParams; use codex_app_server_protocol::CancelLoginChatGptParams; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientNotification; +use codex_app_server_protocol::CollaborationModeListParams; use codex_app_server_protocol::ConfigBatchWriteParams; use codex_app_server_protocol::ConfigReadParams; use codex_app_server_protocol::ConfigValueWriteParams; @@ -396,6 +397,15 @@ impl McpProcess { self.send_request("model/list", params).await } + /// Send a `collaborationMode/list` JSON-RPC request. + pub async fn send_list_collaboration_modes_request( + &mut self, + params: CollaborationModeListParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("collaborationMode/list", params).await + } + /// Send a `resumeConversation` JSON-RPC request. pub async fn send_resume_conversation_request( &mut self, diff --git a/codex-rs/app-server/tests/common/responses.rs b/codex-rs/app-server/tests/common/responses.rs index 35c1862e8f..e15319e02f 100644 --- a/codex-rs/app-server/tests/common/responses.rs +++ b/codex-rs/app-server/tests/common/responses.rs @@ -60,3 +60,26 @@ pub fn create_exec_command_sse_response(call_id: &str) -> anyhow::Result responses::ev_completed("resp-1"), ])) } + +pub fn create_request_user_input_sse_response(call_id: &str) -> anyhow::Result { + let tool_call_arguments = serde_json::to_string(&json!({ + "questions": [{ + "id": "confirm_path", + "header": "Confirm", + "question": "Proceed with the plan?", + "options": [{ + "label": "Yes (Recommended)", + "description": "Continue the current plan." + }, { + "label": "No", + "description": "Stop and revisit the approach." + }] + }] + }))?; + + Ok(responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_function_call(call_id, "request_user_input", &tool_call_arguments), + responses::ev_completed("resp-1"), + ])) +} diff --git a/codex-rs/app-server/tests/common/rollout.rs b/codex-rs/app-server/tests/common/rollout.rs index 784b79b98d..e2e55a6544 100644 --- a/codex-rs/app-server/tests/common/rollout.rs +++ b/codex-rs/app-server/tests/common/rollout.rs @@ -6,10 +6,23 @@ use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::SessionSource; use serde_json::json; use std::fs; +use std::fs::FileTimes; use std::path::Path; use std::path::PathBuf; use uuid::Uuid; +pub fn rollout_path(codex_home: &Path, filename_ts: &str, thread_id: &str) -> PathBuf { + let year = &filename_ts[0..4]; + let month = &filename_ts[5..7]; + let day = &filename_ts[8..10]; + codex_home + .join("sessions") + .join(year) + .join(month) + .join(day) + .join(format!("rollout-{filename_ts}-{thread_id}.jsonl")) +} + /// Create a minimal rollout file under `CODEX_HOME/sessions/YYYY/MM/DD/`. /// /// - `filename_ts` is the filename timestamp component in `YYYY-MM-DDThh-mm-ss` format. @@ -30,25 +43,23 @@ pub fn create_fake_rollout( let uuid_str = uuid.to_string(); let conversation_id = ThreadId::from_string(&uuid_str)?; - // sessions/YYYY/MM/DD derived from filename_ts (YYYY-MM-DDThh-mm-ss) - let year = &filename_ts[0..4]; - let month = &filename_ts[5..7]; - let day = &filename_ts[8..10]; - let dir = codex_home.join("sessions").join(year).join(month).join(day); - fs::create_dir_all(&dir)?; - - let file_path = dir.join(format!("rollout-{filename_ts}-{uuid}.jsonl")); + let file_path = rollout_path(codex_home, filename_ts, &uuid_str); + let dir = file_path + .parent() + .ok_or_else(|| anyhow::anyhow!("missing rollout parent directory"))?; + fs::create_dir_all(dir)?; // Build JSONL lines let meta = SessionMeta { id: conversation_id, + forked_from_id: None, timestamp: meta_rfc3339.to_string(), cwd: PathBuf::from("/"), originator: "codex".to_string(), cli_version: "0.0.0".to_string(), - instructions: None, source: SessionSource::Cli, model_provider: model_provider.map(str::to_string), + base_instructions: None, }; let payload = serde_json::to_value(SessionMetaLine { meta, @@ -84,7 +95,13 @@ pub fn create_fake_rollout( .to_string(), ]; - fs::write(file_path, lines.join("\n") + "\n")?; + fs::write(&file_path, lines.join("\n") + "\n")?; + let parsed = chrono::DateTime::parse_from_rfc3339(meta_rfc3339)?.with_timezone(&chrono::Utc); + let times = FileTimes::new().set_modified(parsed.into()); + std::fs::OpenOptions::new() + .append(true) + .open(&file_path)? + .set_times(times)?; Ok(uuid_str) } @@ -113,13 +130,14 @@ pub fn create_fake_rollout_with_text_elements( // Build JSONL lines let meta = SessionMeta { id: conversation_id, + forked_from_id: None, timestamp: meta_rfc3339.to_string(), cwd: PathBuf::from("/"), originator: "codex".to_string(), cli_version: "0.0.0".to_string(), - instructions: None, source: SessionSource::Cli, model_provider: model_provider.map(str::to_string), + base_instructions: None, }; let payload = serde_json::to_value(SessionMetaLine { meta, diff --git a/codex-rs/app-server/tests/suite/v2/collaboration_mode_list.rs b/codex-rs/app-server/tests/suite/v2/collaboration_mode_list.rs new file mode 100644 index 0000000000..d75a589d00 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/collaboration_mode_list.rs @@ -0,0 +1,86 @@ +//! Validates that the collaboration mode list endpoint returns the expected default presets. +//! +//! The test drives the app server through the MCP harness and asserts that the list response +//! includes the plan, pair programming, and execute modes with their default model and reasoning +//! effort settings, which keeps the API contract visible in one place. + +#![allow(clippy::unwrap_used)] + +use std::time::Duration; + +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::to_response; +use codex_app_server_protocol::CollaborationModeListParams; +use codex_app_server_protocol::CollaborationModeListResponse; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_core::models_manager::test_builtin_collaboration_mode_presets; +use codex_protocol::config_types::CollaborationMode; +use pretty_assertions::assert_eq; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); + +/// Confirms the server returns the default collaboration mode presets in a stable order. +#[tokio::test] +async fn list_collaboration_modes_returns_presets() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_list_collaboration_modes_request(CollaborationModeListParams {}) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + + let CollaborationModeListResponse { data: items } = + to_response::(response)?; + + let expected = vec![plan_preset(), pair_programming_preset(), execute_preset()]; + assert_eq!(expected, items); + Ok(()) +} + +/// Builds the plan preset that the list response is expected to return. +/// +/// If the defaults change in the app server, this helper should be updated alongside the +/// contract, or the test will fail in ways that imply a regression in the API. +fn plan_preset() -> CollaborationMode { + let presets = test_builtin_collaboration_mode_presets(); + presets + .into_iter() + .find(|p| matches!(p, CollaborationMode::Plan(_))) + .unwrap() +} + +/// Builds the pair programming preset that the list response is expected to return. +/// +/// The helper keeps the expected model and reasoning defaults co-located with the test +/// so that mismatches point directly at the API contract being exercised. +fn pair_programming_preset() -> CollaborationMode { + let presets = test_builtin_collaboration_mode_presets(); + presets + .into_iter() + .find(|p| matches!(p, CollaborationMode::PairProgramming(_))) + .unwrap() +} + +/// Builds the execute preset that the list response is expected to return. +/// +/// The execute preset uses a different reasoning effort to capture the higher-effort +/// execution contract the server currently exposes. +fn execute_preset() -> CollaborationMode { + let presets = test_builtin_collaboration_mode_presets(); + presets + .into_iter() + .find(|p| matches!(p, CollaborationMode::Execute(_))) + .unwrap() +} diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index b2159ab9ce..bf6230ae52 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -1,10 +1,12 @@ mod account; mod analytics; +mod collaboration_mode_list; mod config_rpc; mod initialize; mod model_list; mod output_schema; mod rate_limits; +mod request_user_input; mod review; mod thread_archive; mod thread_fork; diff --git a/codex-rs/app-server/tests/suite/v2/request_user_input.rs b/codex-rs/app-server/tests/suite/v2/request_user_input.rs new file mode 100644 index 0000000000..37122e08a2 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/request_user_input.rs @@ -0,0 +1,127 @@ +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_final_assistant_message_sse_response; +use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_request_user_input_sse_response; +use app_test_support::to_response; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::UserInput as V2UserInput; +use codex_protocol::openai_models::ReasoningEffort; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn request_user_input_round_trip() -> Result<()> { + let codex_home = tempfile::TempDir::new()?; + let responses = vec![ + create_request_user_input_sse_response("call1")?, + create_final_assistant_message_sse_response("done")?, + ]; + let server = create_mock_responses_server_sequence(responses).await; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response(thread_start_resp)?; + + let turn_start_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "ask something".to_string(), + text_elements: Vec::new(), + }], + model: Some("mock-model".to_string()), + effort: Some(ReasoningEffort::Medium), + ..Default::default() + }) + .await?; + let turn_start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_start_id)), + ) + .await??; + let TurnStartResponse { turn, .. } = to_response(turn_start_resp)?; + + let server_req = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::ToolRequestUserInput { request_id, params } = server_req else { + panic!("expected ToolRequestUserInput request, got: {server_req:?}"); + }; + + assert_eq!(params.thread_id, thread.id); + assert_eq!(params.turn_id, turn.id); + assert_eq!(params.item_id, "call1"); + assert_eq!(params.questions.len(), 1); + + mcp.send_response( + request_id, + serde_json::json!({ + "answers": { + "confirm_path": { "selected": ["yes"], "other": serde_json::Value::Null } + } + }), + ) + .await?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("codex/event/task_complete"), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + Ok(()) +} + +fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "untrusted" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[features] +collaboration_modes = true + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/suite/v2/thread_list.rs b/codex-rs/app-server/tests/suite/v2/thread_list.rs index 0132651df8..7c3568c637 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -1,17 +1,27 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_fake_rollout; +use app_test_support::rollout_path; use app_test_support::to_response; +use chrono::DateTime; +use chrono::Utc; use codex_app_server_protocol::GitInfo as ApiGitInfo; +use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SessionSource; use codex_app_server_protocol::ThreadListResponse; +use codex_app_server_protocol::ThreadSortKey; use codex_protocol::protocol::GitInfo as CoreGitInfo; +use pretty_assertions::assert_eq; +use std::cmp::Reverse; +use std::fs::FileTimes; +use std::fs::OpenOptions; use std::path::Path; use std::path::PathBuf; use tempfile::TempDir; use tokio::time::timeout; +use uuid::Uuid; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); @@ -26,11 +36,22 @@ async fn list_threads( cursor: Option, limit: Option, providers: Option>, +) -> Result { + list_threads_with_sort(mcp, cursor, limit, providers, None).await +} + +async fn list_threads_with_sort( + mcp: &mut McpProcess, + cursor: Option, + limit: Option, + providers: Option>, + sort_key: Option, ) -> Result { let request_id = mcp .send_thread_list_request(codex_app_server_protocol::ThreadListParams { cursor, limit, + sort_key, model_providers: providers, }) .await?; @@ -82,6 +103,16 @@ fn timestamp_at( ) } +fn set_rollout_mtime(path: &Path, updated_at_rfc3339: &str) -> Result<()> { + let parsed = DateTime::parse_from_rfc3339(updated_at_rfc3339)?.with_timezone(&Utc); + let times = FileTimes::new().set_modified(parsed.into()); + OpenOptions::new() + .append(true) + .open(path)? + .set_times(times)?; + Ok(()) +} + #[tokio::test] async fn thread_list_basic_empty() -> Result<()> { let codex_home = TempDir::new()?; @@ -163,6 +194,7 @@ async fn thread_list_pagination_next_cursor_none_on_last_page() -> Result<()> { assert_eq!(thread.preview, "Hello"); assert_eq!(thread.model_provider, "mock_provider"); assert!(thread.created_at > 0); + assert_eq!(thread.updated_at, thread.created_at); assert_eq!(thread.cwd, PathBuf::from("/")); assert_eq!(thread.cli_version, "0.0.0"); assert_eq!(thread.source, SessionSource::Cli); @@ -186,6 +218,7 @@ async fn thread_list_pagination_next_cursor_none_on_last_page() -> Result<()> { assert_eq!(thread.preview, "Hello"); assert_eq!(thread.model_provider, "mock_provider"); assert!(thread.created_at > 0); + assert_eq!(thread.updated_at, thread.created_at); assert_eq!(thread.cwd, PathBuf::from("/")); assert_eq!(thread.cli_version, "0.0.0"); assert_eq!(thread.source, SessionSource::Cli); @@ -236,6 +269,7 @@ async fn thread_list_respects_provider_filter() -> Result<()> { assert_eq!(thread.model_provider, "other_provider"); let expected_ts = chrono::DateTime::parse_from_rfc3339("2025-01-02T11:00:00Z")?.timestamp(); assert_eq!(thread.created_at, expected_ts); + assert_eq!(thread.updated_at, expected_ts); assert_eq!(thread.cwd, PathBuf::from("/")); assert_eq!(thread.cli_version, "0.0.0"); assert_eq!(thread.source, SessionSource::Cli); @@ -429,3 +463,351 @@ async fn thread_list_includes_git_info() -> Result<()> { Ok(()) } + +#[tokio::test] +async fn thread_list_default_sorts_by_created_at() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + let id_a = create_fake_rollout( + codex_home.path(), + "2025-01-02T12-00-00", + "2025-01-02T12:00:00Z", + "Hello", + Some("mock_provider"), + None, + )?; + let id_b = create_fake_rollout( + codex_home.path(), + "2025-01-01T13-00-00", + "2025-01-01T13:00:00Z", + "Hello", + Some("mock_provider"), + None, + )?; + let id_c = create_fake_rollout( + codex_home.path(), + "2025-01-01T12-00-00", + "2025-01-01T12:00:00Z", + "Hello", + Some("mock_provider"), + None, + )?; + + let mut mcp = init_mcp(codex_home.path()).await?; + + let ThreadListResponse { data, .. } = list_threads_with_sort( + &mut mcp, + None, + Some(10), + Some(vec!["mock_provider".to_string()]), + None, + ) + .await?; + + let ids: Vec<_> = data.iter().map(|thread| thread.id.as_str()).collect(); + assert_eq!(ids, vec![id_a.as_str(), id_b.as_str(), id_c.as_str()]); + + Ok(()) +} + +#[tokio::test] +async fn thread_list_sort_updated_at_orders_by_mtime() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + let id_old = create_fake_rollout( + codex_home.path(), + "2025-01-01T10-00-00", + "2025-01-01T10:00:00Z", + "Hello", + Some("mock_provider"), + None, + )?; + let id_mid = create_fake_rollout( + codex_home.path(), + "2025-01-01T11-00-00", + "2025-01-01T11:00:00Z", + "Hello", + Some("mock_provider"), + None, + )?; + let id_new = create_fake_rollout( + codex_home.path(), + "2025-01-01T12-00-00", + "2025-01-01T12:00:00Z", + "Hello", + Some("mock_provider"), + None, + )?; + + set_rollout_mtime( + rollout_path(codex_home.path(), "2025-01-01T10-00-00", &id_old).as_path(), + "2025-01-03T00:00:00Z", + )?; + set_rollout_mtime( + rollout_path(codex_home.path(), "2025-01-01T11-00-00", &id_mid).as_path(), + "2025-01-02T00:00:00Z", + )?; + set_rollout_mtime( + rollout_path(codex_home.path(), "2025-01-01T12-00-00", &id_new).as_path(), + "2025-01-01T00:00:00Z", + )?; + + let mut mcp = init_mcp(codex_home.path()).await?; + + let ThreadListResponse { data, .. } = list_threads_with_sort( + &mut mcp, + None, + Some(10), + Some(vec!["mock_provider".to_string()]), + Some(ThreadSortKey::UpdatedAt), + ) + .await?; + + let ids: Vec<_> = data.iter().map(|thread| thread.id.as_str()).collect(); + assert_eq!(ids, vec![id_old.as_str(), id_mid.as_str(), id_new.as_str()]); + + Ok(()) +} + +#[tokio::test] +async fn thread_list_updated_at_paginates_with_cursor() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + let id_a = create_fake_rollout( + codex_home.path(), + "2025-02-01T10-00-00", + "2025-02-01T10:00:00Z", + "Hello", + Some("mock_provider"), + None, + )?; + let id_b = create_fake_rollout( + codex_home.path(), + "2025-02-01T11-00-00", + "2025-02-01T11:00:00Z", + "Hello", + Some("mock_provider"), + None, + )?; + let id_c = create_fake_rollout( + codex_home.path(), + "2025-02-01T12-00-00", + "2025-02-01T12:00:00Z", + "Hello", + Some("mock_provider"), + None, + )?; + + set_rollout_mtime( + rollout_path(codex_home.path(), "2025-02-01T10-00-00", &id_a).as_path(), + "2025-02-03T00:00:00Z", + )?; + set_rollout_mtime( + rollout_path(codex_home.path(), "2025-02-01T11-00-00", &id_b).as_path(), + "2025-02-02T00:00:00Z", + )?; + set_rollout_mtime( + rollout_path(codex_home.path(), "2025-02-01T12-00-00", &id_c).as_path(), + "2025-02-01T00:00:00Z", + )?; + + let mut mcp = init_mcp(codex_home.path()).await?; + + let ThreadListResponse { + data: page1, + next_cursor: cursor1, + } = list_threads_with_sort( + &mut mcp, + None, + Some(2), + Some(vec!["mock_provider".to_string()]), + Some(ThreadSortKey::UpdatedAt), + ) + .await?; + let ids_page1: Vec<_> = page1.iter().map(|thread| thread.id.as_str()).collect(); + assert_eq!(ids_page1, vec![id_a.as_str(), id_b.as_str()]); + let cursor1 = cursor1.expect("expected nextCursor on first page"); + + let ThreadListResponse { + data: page2, + next_cursor: cursor2, + } = list_threads_with_sort( + &mut mcp, + Some(cursor1), + Some(2), + Some(vec!["mock_provider".to_string()]), + Some(ThreadSortKey::UpdatedAt), + ) + .await?; + let ids_page2: Vec<_> = page2.iter().map(|thread| thread.id.as_str()).collect(); + assert_eq!(ids_page2, vec![id_c.as_str()]); + assert_eq!(cursor2, None); + + Ok(()) +} + +#[tokio::test] +async fn thread_list_created_at_tie_breaks_by_uuid() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + let id_a = create_fake_rollout( + codex_home.path(), + "2025-02-01T10-00-00", + "2025-02-01T10:00:00Z", + "Hello", + Some("mock_provider"), + None, + )?; + let id_b = create_fake_rollout( + codex_home.path(), + "2025-02-01T10-00-00", + "2025-02-01T10:00:00Z", + "Hello", + Some("mock_provider"), + None, + )?; + + let mut mcp = init_mcp(codex_home.path()).await?; + + let ThreadListResponse { data, .. } = list_threads( + &mut mcp, + None, + Some(10), + Some(vec!["mock_provider".to_string()]), + ) + .await?; + + let ids: Vec<_> = data.iter().map(|thread| thread.id.as_str()).collect(); + let mut expected = [id_a, id_b]; + expected.sort_by_key(|id| Reverse(Uuid::parse_str(id).expect("uuid should parse"))); + let expected: Vec<_> = expected.iter().map(String::as_str).collect(); + assert_eq!(ids, expected); + + Ok(()) +} + +#[tokio::test] +async fn thread_list_updated_at_tie_breaks_by_uuid() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + let id_a = create_fake_rollout( + codex_home.path(), + "2025-02-01T10-00-00", + "2025-02-01T10:00:00Z", + "Hello", + Some("mock_provider"), + None, + )?; + let id_b = create_fake_rollout( + codex_home.path(), + "2025-02-01T11-00-00", + "2025-02-01T11:00:00Z", + "Hello", + Some("mock_provider"), + None, + )?; + + let updated_at = "2025-02-03T00:00:00Z"; + set_rollout_mtime( + rollout_path(codex_home.path(), "2025-02-01T10-00-00", &id_a).as_path(), + updated_at, + )?; + set_rollout_mtime( + rollout_path(codex_home.path(), "2025-02-01T11-00-00", &id_b).as_path(), + updated_at, + )?; + + let mut mcp = init_mcp(codex_home.path()).await?; + + let ThreadListResponse { data, .. } = list_threads_with_sort( + &mut mcp, + None, + Some(10), + Some(vec!["mock_provider".to_string()]), + Some(ThreadSortKey::UpdatedAt), + ) + .await?; + + let ids: Vec<_> = data.iter().map(|thread| thread.id.as_str()).collect(); + let mut expected = [id_a, id_b]; + expected.sort_by_key(|id| Reverse(Uuid::parse_str(id).expect("uuid should parse"))); + let expected: Vec<_> = expected.iter().map(String::as_str).collect(); + assert_eq!(ids, expected); + + Ok(()) +} + +#[tokio::test] +async fn thread_list_updated_at_uses_mtime() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + let thread_id = create_fake_rollout( + codex_home.path(), + "2025-02-01T10-00-00", + "2025-02-01T10:00:00Z", + "Hello", + Some("mock_provider"), + None, + )?; + + set_rollout_mtime( + rollout_path(codex_home.path(), "2025-02-01T10-00-00", &thread_id).as_path(), + "2025-02-05T00:00:00Z", + )?; + + let mut mcp = init_mcp(codex_home.path()).await?; + + let ThreadListResponse { data, .. } = list_threads_with_sort( + &mut mcp, + None, + Some(10), + Some(vec!["mock_provider".to_string()]), + Some(ThreadSortKey::UpdatedAt), + ) + .await?; + + let thread = data + .iter() + .find(|item| item.id == thread_id) + .expect("expected thread for created rollout"); + let expected_created = + chrono::DateTime::parse_from_rfc3339("2025-02-01T10:00:00Z")?.timestamp(); + let expected_updated = + chrono::DateTime::parse_from_rfc3339("2025-02-05T00:00:00Z")?.timestamp(); + assert_eq!(thread.created_at, expected_created); + assert_eq!(thread.updated_at, expected_updated); + + Ok(()) +} + +#[tokio::test] +async fn thread_list_invalid_cursor_returns_error() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + let mut mcp = init_mcp(codex_home.path()).await?; + + let request_id = mcp + .send_thread_list_request(codex_app_server_protocol::ThreadListParams { + cursor: Some("not-a-cursor".to_string()), + limit: Some(2), + sort_key: None, + model_providers: Some(vec!["mock_provider".to_string()]), + }) + .await?; + let error: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + assert_eq!(error.error.code, -32600); + assert_eq!(error.error.message, "invalid cursor: not-a-cursor"); + + Ok(()) +} diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index be2971397d..89b127d399 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -74,10 +74,10 @@ async fn thread_resume_returns_rollout_history() -> Result<()> { create_config_toml(codex_home.path(), &server.uri())?; let preview = "Saved user message"; - let text_elements = vec![TextElement { - byte_range: ByteRange { start: 0, end: 5 }, - placeholder: Some("".into()), - }]; + let text_elements = vec![TextElement::new( + ByteRange { start: 0, end: 5 }, + Some("".into()), + )]; let conversation_id = create_fake_rollout_with_text_elements( codex_home.path(), "2025-01-05T12-00-00", diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index e3b758a4c6..c465444709 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -35,7 +35,10 @@ use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput as V2UserInput; use codex_core::protocol_config_types::ReasoningSummary; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; +use core_test_support::responses; use core_test_support::skip_if_no_network; use pretty_assertions::assert_eq; use std::path::Path; @@ -139,10 +142,10 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { .await??; let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; - let text_elements = vec![TextElement { - byte_range: ByteRange { start: 0, end: 5 }, - placeholder: Some("".to_string()), - }]; + let text_elements = vec![TextElement::new( + ByteRange { start: 0, end: 5 }, + Some("".to_string()), + )]; let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), @@ -305,6 +308,77 @@ async fn turn_start_emits_notifications_and_accepts_model_override() -> Result<( Ok(()) } +#[tokio::test] +async fn turn_start_accepts_collaboration_mode_override_v2() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]); + let response_mock = responses::mount_sse_once(&server, body).await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let collaboration_mode = CollaborationMode::Custom(Settings { + model: "mock-model-collab".to_string(), + reasoning_effort: Some(ReasoningEffort::High), + developer_instructions: None, + }); + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + model: Some("mock-model-override".to_string()), + effort: Some(ReasoningEffort::Low), + summary: Some(ReasoningSummary::Auto), + output_schema: None, + collaboration_mode: Some(collaboration_mode), + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _turn: TurnStartResponse = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let request = response_mock.single_request(); + let payload = request.body_json(); + assert_eq!(payload["model"].as_str(), Some("mock-model-collab")); + + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_local_image_input() -> Result<()> { // Two Codex turns hit the mock model (session start + turn/start). @@ -703,6 +777,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { effort: Some(ReasoningEffort::Medium), summary: Some(ReasoningSummary::Auto), output_schema: None, + collaboration_mode: None, }) .await?; timeout( @@ -732,6 +807,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { effort: Some(ReasoningEffort::Medium), summary: Some(ReasoningSummary::Auto), output_schema: None, + collaboration_mode: None, }) .await?; timeout( @@ -1395,8 +1471,18 @@ unified_exec = true unreachable!("loop ensures we break on command execution items"); }; assert_eq!(completed_id, "uexec-1"); - assert_eq!(completed_status, CommandExecutionStatus::Completed); - assert_eq!(exit_code, Some(0)); + assert!( + matches!( + completed_status, + CommandExecutionStatus::Completed | CommandExecutionStatus::Failed + ), + "unexpected command execution status: {completed_status:?}" + ); + if completed_status == CommandExecutionStatus::Completed { + assert_eq!(exit_code, Some(0)); + } else { + assert!(exit_code.is_some(), "expected exit_code for failed command"); + } assert_eq!( completed_process_id.as_deref(), Some(started_process_id.as_str()) diff --git a/codex-rs/backend-client/Cargo.toml b/codex-rs/backend-client/Cargo.toml index ec5546a670..b369953495 100644 --- a/codex-rs/backend-client/Cargo.toml +++ b/codex-rs/backend-client/Cargo.toml @@ -12,7 +12,7 @@ path = "src/lib.rs" anyhow = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-webpki-roots", "rustls-tls-native-roots"] } codex-backend-openapi-models = { path = "../codex-backend-openapi-models" } codex-protocol = { workspace = true } codex-core = { workspace = true } diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index fdd4504bb6..ea3585956b 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -174,6 +174,7 @@ impl Client { limit: Option, task_filter: Option<&str>, environment_id: Option<&str>, + cursor: Option<&str>, ) -> Result { let url = match self.path_style { PathStyle::CodexApi => format!("{}/api/codex/tasks/list", self.base_url), @@ -190,6 +191,11 @@ impl Client { } else { req }; + let req = if let Some(c) = cursor { + req.query(&[("cursor", c)]) + } else { + req + }; let req = if let Some(id) = environment_id { req.query(&[("environment_id", id)]) } else { diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 1c82eae004..25c1161c8d 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -14,11 +14,9 @@ use codex_cli::login::run_login_status; use codex_cli::login::run_login_with_api_key; use codex_cli::login::run_login_with_chatgpt; use codex_cli::login::run_login_with_device_code; -use codex_cli::login::run_login_with_device_code_fallback_to_browser; use codex_cli::login::run_logout; use codex_cloud_tasks::Cli as CloudTasksCli; use codex_common::CliConfigOverrides; -use codex_core::env::is_headless_environment; use codex_exec::Cli as ExecCli; use codex_exec::Command as ExecCommand; use codex_exec::ReviewArgs; @@ -461,8 +459,8 @@ enum FeaturesSubcommand { fn stage_str(stage: codex_core::features::Stage) -> &'static str { use codex_core::features::Stage; match stage { - Stage::Experimental => "experimental", - Stage::Beta { .. } => "beta", + Stage::Beta => "experimental", + Stage::Experimental { .. } => "beta", Stage::Stable => "stable", Stage::Deprecated => "deprecated", Stage::Removed => "removed", @@ -600,13 +598,6 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() } else if login_cli.with_api_key { let api_key = read_api_key_from_stdin(); run_login_with_api_key(login_cli.config_overrides, api_key).await; - } else if is_headless_environment() { - run_login_with_device_code_fallback_to_browser( - login_cli.config_overrides, - login_cli.issuer_base_url, - login_cli.client_id, - ) - .await; } else { run_login_with_chatgpt(login_cli.config_overrides).await; } @@ -737,9 +728,13 @@ fn prepend_config_flags( /// Run the interactive Codex TUI, dispatching to either the legacy implementation or the /// experimental TUI v2 shim based on feature flags resolved from config. async fn run_interactive_tui( - interactive: TuiCli, + mut interactive: TuiCli, codex_linux_sandbox_exe: Option, ) -> std::io::Result { + if let Some(prompt) = interactive.prompt.take() { + // Normalize CRLF/CR to LF so CLI-provided text can't leak `\r` into TUI state. + interactive.prompt = Some(prompt.replace("\r\n", "\n").replace('\r', "\n")); + } if is_tui2_enabled(&interactive).await? { let result = tui2::run_main(interactive.into(), codex_linux_sandbox_exe).await?; Ok(result.into()) @@ -864,7 +859,8 @@ fn merge_interactive_cli_flags(interactive: &mut TuiCli, subcommand_cli: TuiCli) interactive.add_dir.extend(subcommand_cli.add_dir); } if let Some(prompt) = subcommand_cli.prompt { - interactive.prompt = Some(prompt); + // Normalize CRLF/CR to LF so CLI-provided text can't leak `\r` into TUI state. + interactive.prompt = Some(prompt.replace("\r\n", "\n").replace('\r', "\n")); } interactive diff --git a/codex-rs/cloud-tasks-client/src/api.rs b/codex-rs/cloud-tasks-client/src/api.rs index cd8228bc28..7059bdb39f 100644 --- a/codex-rs/cloud-tasks-client/src/api.rs +++ b/codex-rs/cloud-tasks-client/src/api.rs @@ -94,6 +94,12 @@ pub struct CreatedTask { pub id: TaskId, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TaskListPage { + pub tasks: Vec, + pub cursor: Option, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct DiffSummary { pub files_changed: usize, @@ -126,7 +132,12 @@ impl Default for TaskText { #[async_trait::async_trait] pub trait CloudBackend: Send + Sync { - async fn list_tasks(&self, env: Option<&str>) -> Result>; + async fn list_tasks( + &self, + env: Option<&str>, + limit: Option, + cursor: Option<&str>, + ) -> Result; async fn get_task_summary(&self, id: TaskId) -> Result; async fn get_task_diff(&self, id: TaskId) -> Result>; /// Return assistant output messages (no diff) when available. diff --git a/codex-rs/cloud-tasks-client/src/http.rs b/codex-rs/cloud-tasks-client/src/http.rs index f55d0fe797..e6990b7ebd 100644 --- a/codex-rs/cloud-tasks-client/src/http.rs +++ b/codex-rs/cloud-tasks-client/src/http.rs @@ -6,6 +6,7 @@ use crate::CloudTaskError; use crate::DiffSummary; use crate::Result; use crate::TaskId; +use crate::TaskListPage; use crate::TaskStatus; use crate::TaskSummary; use crate::TurnAttempt; @@ -59,8 +60,13 @@ impl HttpClient { #[async_trait::async_trait] impl CloudBackend for HttpClient { - async fn list_tasks(&self, env: Option<&str>) -> Result> { - self.tasks_api().list(env).await + async fn list_tasks( + &self, + env: Option<&str>, + limit: Option, + cursor: Option<&str>, + ) -> Result { + self.tasks_api().list(env, limit, cursor).await } async fn get_task_summary(&self, id: TaskId) -> Result { @@ -132,10 +138,16 @@ mod api { } } - pub(crate) async fn list(&self, env: Option<&str>) -> Result> { + pub(crate) async fn list( + &self, + env: Option<&str>, + limit: Option, + cursor: Option<&str>, + ) -> Result { + let limit_i32 = limit.and_then(|lim| i32::try_from(lim).ok()); let resp = self .backend - .list_tasks(Some(20), Some("current"), env) + .list_tasks(limit_i32, Some("current"), env, cursor) .await .map_err(|e| CloudTaskError::Http(format!("list_tasks failed: {e}")))?; @@ -146,11 +158,19 @@ mod api { .collect(); append_error_log(&format!( - "http.list_tasks: env={} items={}", + "http.list_tasks: env={} limit={} cursor_in={} cursor_out={} items={}", env.unwrap_or(""), + limit_i32 + .map(|v| v.to_string()) + .unwrap_or_else(|| "".to_string()), + cursor.unwrap_or(""), + resp.cursor.as_deref().unwrap_or(""), tasks.len() )); - Ok(tasks) + Ok(TaskListPage { + tasks, + cursor: resp.cursor, + }) } pub(crate) async fn summary(&self, id: TaskId) -> Result { diff --git a/codex-rs/cloud-tasks-client/src/lib.rs b/codex-rs/cloud-tasks-client/src/lib.rs index a723512f8a..b28b356f2a 100644 --- a/codex-rs/cloud-tasks-client/src/lib.rs +++ b/codex-rs/cloud-tasks-client/src/lib.rs @@ -9,6 +9,7 @@ pub use api::CreatedTask; pub use api::DiffSummary; pub use api::Result; pub use api::TaskId; +pub use api::TaskListPage; pub use api::TaskStatus; pub use api::TaskSummary; pub use api::TaskText; diff --git a/codex-rs/cloud-tasks-client/src/mock.rs b/codex-rs/cloud-tasks-client/src/mock.rs index 2d03cea029..f6e14e61a2 100644 --- a/codex-rs/cloud-tasks-client/src/mock.rs +++ b/codex-rs/cloud-tasks-client/src/mock.rs @@ -16,7 +16,12 @@ pub struct MockClient; #[async_trait::async_trait] impl CloudBackend for MockClient { - async fn list_tasks(&self, _env: Option<&str>) -> Result> { + async fn list_tasks( + &self, + _env: Option<&str>, + _limit: Option, + _cursor: Option<&str>, + ) -> Result { // Slightly vary content by env to aid tests that rely on the mock let rows = match _env { Some("env-A") => vec![("T-2000", "A: First", TaskStatus::Ready)], @@ -58,11 +63,14 @@ impl CloudBackend for MockClient { attempt_total: Some(if id_str == "T-1000" { 2 } else { 1 }), }); } - Ok(out) + Ok(crate::TaskListPage { + tasks: out, + cursor: None, + }) } async fn get_task_summary(&self, id: TaskId) -> Result { - let tasks = self.list_tasks(None).await?; + let tasks = self.list_tasks(None, None, None).await?.tasks; tasks .into_iter() .find(|t| t.id == id) diff --git a/codex-rs/cloud-tasks/src/app.rs b/codex-rs/cloud-tasks/src/app.rs index ce12128a3e..ac3dd9e8df 100644 --- a/codex-rs/cloud-tasks/src/app.rs +++ b/codex-rs/cloud-tasks/src/app.rs @@ -123,9 +123,13 @@ pub async fn load_tasks( env: Option<&str>, ) -> anyhow::Result> { // In later milestones, add a small debounce, spinner, and error display. - let tasks = tokio::time::timeout(Duration::from_secs(5), backend.list_tasks(env)).await??; + let tasks = tokio::time::timeout( + Duration::from_secs(5), + backend.list_tasks(env, Some(20), None), + ) + .await??; // Hide review-only tasks from the main list. - let filtered: Vec = tasks.into_iter().filter(|t| !t.is_review).collect(); + let filtered: Vec = tasks.tasks.into_iter().filter(|t| !t.is_review).collect(); Ok(filtered) } @@ -362,7 +366,9 @@ mod tests { async fn list_tasks( &self, env: Option<&str>, - ) -> codex_cloud_tasks_client::Result> { + limit: Option, + cursor: Option<&str>, + ) -> codex_cloud_tasks_client::Result { let key = env.map(str::to_string); let titles = self .by_env @@ -383,15 +389,28 @@ mod tests { attempt_total: Some(1), }); } - Ok(out) + let max = limit.unwrap_or(i64::MAX); + let max = max.min(20); + let mut limited = Vec::new(); + for task in out { + if (limited.len() as i64) >= max { + break; + } + limited.push(task); + } + Ok(codex_cloud_tasks_client::TaskListPage { + tasks: limited, + cursor: cursor.map(str::to_string), + }) } async fn get_task_summary( &self, id: TaskId, ) -> codex_cloud_tasks_client::Result { - self.list_tasks(None) + self.list_tasks(None, None, None) .await? + .tasks .into_iter() .find(|t| t.id == id) .ok_or_else(|| CloudTaskError::Msg(format!("Task {} not found", id.0))) diff --git a/codex-rs/cloud-tasks/src/cli.rs b/codex-rs/cloud-tasks/src/cli.rs index 6b36509639..595c649b38 100644 --- a/codex-rs/cloud-tasks/src/cli.rs +++ b/codex-rs/cloud-tasks/src/cli.rs @@ -18,6 +18,8 @@ pub enum Command { Exec(ExecCommand), /// Show the status of a Codex Cloud task. Status(StatusCommand), + /// List Codex Cloud tasks. + List(ListCommand), /// Apply the diff for a Codex Cloud task locally. Apply(ApplyCommand), /// Show the unified diff for a Codex Cloud task. @@ -58,6 +60,17 @@ fn parse_attempts(input: &str) -> Result { } } +fn parse_limit(input: &str) -> Result { + let value: i64 = input + .parse() + .map_err(|_| "limit must be an integer between 1 and 20".to_string())?; + if (1..=20).contains(&value) { + Ok(value) + } else { + Err("limit must be between 1 and 20".to_string()) + } +} + #[derive(Debug, Args)] pub struct StatusCommand { /// Codex Cloud task identifier to inspect. @@ -65,6 +78,25 @@ pub struct StatusCommand { pub task_id: String, } +#[derive(Debug, Args)] +pub struct ListCommand { + /// Filter tasks by environment identifier. + #[arg(long = "env", value_name = "ENV_ID")] + pub environment: Option, + + /// Maximum number of tasks to return (1-20). + #[arg(long = "limit", default_value_t = 20, value_parser = parse_limit, value_name = "N")] + pub limit: i64, + + /// Pagination cursor returned by a previous call. + #[arg(long = "cursor", value_name = "CURSOR")] + pub cursor: Option, + + /// Emit JSON instead of plain text. + #[arg(long = "json", default_value_t = false)] + pub json: bool, +} + #[derive(Debug, Args)] pub struct ApplyCommand { /// Codex Cloud task identifier to apply. diff --git a/codex-rs/cloud-tasks/src/lib.rs b/codex-rs/cloud-tasks/src/lib.rs index e1bedbc1ce..b1d42fb86f 100644 --- a/codex-rs/cloud-tasks/src/lib.rs +++ b/codex-rs/cloud-tasks/src/lib.rs @@ -393,11 +393,10 @@ fn summary_line(summary: &codex_cloud_tasks_client::DiffSummary, colorize: bool) let bullet = "•" .if_supports_color(Stream::Stdout, |t| t.dimmed()) .to_string(); - let file_label = "file" + let file_label = format!("file{}", if files == 1 { "" } else { "s" }) .if_supports_color(Stream::Stdout, |t| t.dimmed()) .to_string(); - let plural = if files == 1 { "" } else { "s" }; - format!("{adds_str}/{dels_str} {bullet} {files} {file_label}{plural}") + format!("{adds_str}/{dels_str} {bullet} {files} {file_label}") } else { format!( "+{adds}/-{dels} • {files} file{}", @@ -473,6 +472,25 @@ fn format_task_status_lines( lines } +fn format_task_list_lines( + tasks: &[codex_cloud_tasks_client::TaskSummary], + base_url: &str, + now: chrono::DateTime, + colorize: bool, +) -> Vec { + let mut lines = Vec::new(); + for (idx, task) in tasks.iter().enumerate() { + lines.push(util::task_url(base_url, &task.id.0)); + for line in format_task_status_lines(task, now, colorize) { + lines.push(format!(" {line}")); + } + if idx + 1 < tasks.len() { + lines.push(String::new()); + } + } + lines +} + async fn run_status_command(args: crate::cli::StatusCommand) -> anyhow::Result<()> { let ctx = init_backend("codex_cloud_tasks_status").await?; let task_id = parse_task_id(&args.task_id)?; @@ -489,6 +507,73 @@ async fn run_status_command(args: crate::cli::StatusCommand) -> anyhow::Result<( Ok(()) } +async fn run_list_command(args: crate::cli::ListCommand) -> anyhow::Result<()> { + let ctx = init_backend("codex_cloud_tasks_list").await?; + let env_filter = if let Some(env) = args.environment { + Some(resolve_environment_id(&ctx, &env).await?) + } else { + None + }; + let page = codex_cloud_tasks_client::CloudBackend::list_tasks( + &*ctx.backend, + env_filter.as_deref(), + Some(args.limit), + args.cursor.as_deref(), + ) + .await?; + if args.json { + let tasks: Vec<_> = page + .tasks + .iter() + .map(|task| { + serde_json::json!({ + "id": task.id.0, + "url": util::task_url(&ctx.base_url, &task.id.0), + "title": task.title, + "status": task.status, + "updated_at": task.updated_at, + "environment_id": task.environment_id, + "environment_label": task.environment_label, + "summary": { + "files_changed": task.summary.files_changed, + "lines_added": task.summary.lines_added, + "lines_removed": task.summary.lines_removed, + }, + "is_review": task.is_review, + "attempt_total": task.attempt_total, + }) + }) + .collect(); + let payload = serde_json::json!({ + "tasks": tasks, + "cursor": page.cursor, + }); + println!("{}", serde_json::to_string_pretty(&payload)?); + return Ok(()); + } + if page.tasks.is_empty() { + println!("No tasks found."); + return Ok(()); + } + let now = Utc::now(); + let colorize = supports_color::on(SupportStream::Stdout).is_some(); + for line in format_task_list_lines(&page.tasks, &ctx.base_url, now, colorize) { + println!("{line}"); + } + if let Some(cursor) = page.cursor { + let command = format!("codex cloud list --cursor='{cursor}'"); + if colorize { + println!( + "\nTo fetch the next page, run {}", + command.if_supports_color(Stream::Stdout, |text| text.cyan()) + ); + } else { + println!("\nTo fetch the next page, run {command}"); + } + } + Ok(()) +} + async fn run_diff_command(args: crate::cli::DiffCommand) -> anyhow::Result<()> { let ctx = init_backend("codex_cloud_tasks_diff").await?; let task_id = parse_task_id(&args.task_id)?; @@ -649,6 +734,7 @@ pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option) -> an return match command { crate::cli::Command::Exec(args) => run_exec_command(args).await, crate::cli::Command::Status(args) => run_status_command(args).await, + crate::cli::Command::List(args) => run_list_command(args).await, crate::cli::Command::Apply(args) => run_apply_command(args).await, crate::cli::Command::Diff(args) => run_diff_command(args).await, }; @@ -2181,6 +2267,54 @@ mod tests { ); } + #[test] + fn format_task_list_lines_formats_urls() { + let now = Utc::now(); + let tasks = vec![ + TaskSummary { + id: TaskId("task_1".to_string()), + title: "Example task".to_string(), + status: TaskStatus::Ready, + updated_at: now, + environment_id: Some("env-1".to_string()), + environment_label: Some("Env".to_string()), + summary: DiffSummary { + files_changed: 3, + lines_added: 5, + lines_removed: 2, + }, + is_review: false, + attempt_total: None, + }, + TaskSummary { + id: TaskId("task_2".to_string()), + title: "No diff task".to_string(), + status: TaskStatus::Pending, + updated_at: now, + environment_id: Some("env-2".to_string()), + environment_label: None, + summary: DiffSummary::default(), + is_review: false, + attempt_total: Some(1), + }, + ]; + let lines = format_task_list_lines(&tasks, "https://chatgpt.com/backend-api", now, false); + assert_eq!( + lines, + vec![ + "https://chatgpt.com/codex/tasks/task_1".to_string(), + " [READY] Example task".to_string(), + " Env • 0s ago".to_string(), + " +5/-2 • 3 files".to_string(), + String::new(), + "https://chatgpt.com/codex/tasks/task_2".to_string(), + " [PENDING] No diff task".to_string(), + " env-2 • 0s ago".to_string(), + " no diff".to_string(), + ] + ); + } + #[tokio::test] async fn collect_attempt_diffs_includes_sibling_attempts() { let backend = MockClient; diff --git a/codex-rs/cloud-tasks/tests/env_filter.rs b/codex-rs/cloud-tasks/tests/env_filter.rs index 8c737c6c28..688ccd29bd 100644 --- a/codex-rs/cloud-tasks/tests/env_filter.rs +++ b/codex-rs/cloud-tasks/tests/env_filter.rs @@ -5,18 +5,23 @@ use codex_cloud_tasks_client::MockClient; async fn mock_backend_varies_by_env() { let client = MockClient; - let root = CloudBackend::list_tasks(&client, None).await.unwrap(); + let root = CloudBackend::list_tasks(&client, None, None, None) + .await + .unwrap() + .tasks; assert!(root.iter().any(|t| t.title.contains("Update README"))); - let a = CloudBackend::list_tasks(&client, Some("env-A")) + let a = CloudBackend::list_tasks(&client, Some("env-A"), None, None) .await - .unwrap(); + .unwrap() + .tasks; assert_eq!(a.len(), 1); assert_eq!(a[0].title, "A: First"); - let b = CloudBackend::list_tasks(&client, Some("env-B")) + let b = CloudBackend::list_tasks(&client, Some("env-B"), None, None) .await - .unwrap(); + .unwrap() + .tasks; assert_eq!(b.len(), 2); assert!(b[0].title.starts_with("B: ")); } diff --git a/codex-rs/codex-api/src/common.rs b/codex-rs/codex-api/src/common.rs index 2118cf66e4..9a7aab9973 100644 --- a/codex-rs/codex-api/src/common.rs +++ b/codex-rs/codex-api/src/common.rs @@ -42,6 +42,10 @@ pub enum ResponseEvent { Created, OutputItemDone(ResponseItem), OutputItemAdded(ResponseItem), + /// Emitted when `X-Reasoning-Included: true` is present on the response, + /// meaning the server already accounted for past reasoning tokens and the + /// client should not re-estimate them. + ServerReasoningIncluded(bool), Completed { response_id: String, token_usage: Option, diff --git a/codex-rs/codex-api/src/endpoint/chat.rs b/codex-rs/codex-api/src/endpoint/chat.rs index f747c57411..8fe1d2a521 100644 --- a/codex-rs/codex-api/src/endpoint/chat.rs +++ b/codex-rs/codex-api/src/endpoint/chat.rs @@ -87,6 +87,7 @@ impl ChatClient { extra_headers, RequestCompression::None, spawn_chat_stream, + None, ) .await } @@ -156,6 +157,9 @@ impl Stream for AggregatedStream { return Poll::Ready(Some(Ok(ResponseEvent::OutputItemDone(item)))); } + Poll::Ready(Some(Ok(ResponseEvent::ServerReasoningIncluded(included)))) => { + return Poll::Ready(Some(Ok(ResponseEvent::ServerReasoningIncluded(included)))); + } Poll::Ready(Some(Ok(ResponseEvent::RateLimits(snapshot)))) => { return Poll::Ready(Some(Ok(ResponseEvent::RateLimits(snapshot)))); } diff --git a/codex-rs/codex-api/src/endpoint/responses.rs b/codex-rs/codex-api/src/endpoint/responses.rs index 57c7b0da03..4aded33911 100644 --- a/codex-rs/codex-api/src/endpoint/responses.rs +++ b/codex-rs/codex-api/src/endpoint/responses.rs @@ -19,6 +19,7 @@ use codex_protocol::protocol::SessionSource; use http::HeaderMap; use serde_json::Value; use std::sync::Arc; +use std::sync::OnceLock; use tracing::instrument; pub struct ResponsesClient { @@ -36,6 +37,7 @@ pub struct ResponsesOptions { pub session_source: Option, pub extra_headers: HeaderMap, pub compression: Compression, + pub turn_state: Option>>, } impl ResponsesClient { @@ -58,9 +60,15 @@ impl ResponsesClient { pub async fn stream_request( &self, request: ResponsesRequest, + turn_state: Option>>, ) -> Result { - self.stream(request.body, request.headers, request.compression) - .await + self.stream( + request.body, + request.headers, + request.compression, + turn_state, + ) + .await } #[instrument(level = "trace", skip_all, err)] @@ -80,6 +88,7 @@ impl ResponsesClient { session_source, extra_headers, compression, + turn_state, } = options; let request = ResponsesRequestBuilder::new(model, &prompt.instructions, &prompt.input) @@ -96,7 +105,7 @@ impl ResponsesClient { .compression(compression) .build(self.streaming.provider())?; - self.stream_request(request).await + self.stream_request(request, turn_state).await } fn path(&self) -> &'static str { @@ -111,6 +120,7 @@ impl ResponsesClient { body: Value, extra_headers: HeaderMap, compression: Compression, + turn_state: Option>>, ) -> Result { let compression = match compression { Compression::None => RequestCompression::None, @@ -124,6 +134,7 @@ impl ResponsesClient { extra_headers, compression, spawn_response_stream, + turn_state, ) .await } diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index af3335ce76..d65caad869 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -13,6 +13,7 @@ use http::HeaderMap; use http::HeaderValue; use serde_json::Value; use std::sync::Arc; +use std::sync::OnceLock; use std::time::Duration; use tokio::net::TcpStream; use tokio::sync::Mutex; @@ -27,18 +28,22 @@ use tracing::trace; use url::Url; type WsStream = WebSocketStream>; +const X_CODEX_TURN_STATE_HEADER: &str = "x-codex-turn-state"; +const X_REASONING_INCLUDED_HEADER: &str = "x-reasoning-included"; pub struct ResponsesWebsocketConnection { stream: Arc>>, // TODO (pakrym): is this the right place for timeout? idle_timeout: Duration, + server_reasoning_included: bool, } impl ResponsesWebsocketConnection { - fn new(stream: WsStream, idle_timeout: Duration) -> Self { + fn new(stream: WsStream, idle_timeout: Duration, server_reasoning_included: bool) -> Self { Self { stream: Arc::new(Mutex::new(Some(stream))), idle_timeout, + server_reasoning_included, } } @@ -54,11 +59,17 @@ impl ResponsesWebsocketConnection { mpsc::channel::>(1600); let stream = Arc::clone(&self.stream); let idle_timeout = self.idle_timeout; + let server_reasoning_included = self.server_reasoning_included; let request_body = serde_json::to_value(&request).map_err(|err| { ApiError::Stream(format!("failed to encode websocket request: {err}")) })?; tokio::spawn(async move { + if server_reasoning_included { + let _ = tx_event + .send(Ok(ResponseEvent::ServerReasoningIncluded(true))) + .await; + } let mut guard = stream.lock().await; let Some(ws_stream) = guard.as_mut() else { let _ = tx_event @@ -100,6 +111,7 @@ impl ResponsesWebsocketClient { pub async fn connect( &self, extra_headers: HeaderMap, + turn_state: Option>>, ) -> Result { let ws_url = Url::parse(&self.provider.url_for_path("responses")) .map_err(|err| ApiError::Stream(format!("failed to build websocket URL: {err}")))?; @@ -108,10 +120,12 @@ impl ResponsesWebsocketClient { headers.extend(extra_headers); apply_auth_headers(&mut headers, &self.auth); - let stream = connect_websocket(ws_url, headers).await?; + let (stream, server_reasoning_included) = + connect_websocket(ws_url, headers, turn_state).await?; Ok(ResponsesWebsocketConnection::new( stream, self.provider.stream_idle_timeout, + server_reasoning_included, )) } } @@ -130,17 +144,30 @@ fn apply_auth_headers(headers: &mut HeaderMap, auth: &impl AuthProvider) { } } -async fn connect_websocket(url: Url, headers: HeaderMap) -> Result { +async fn connect_websocket( + url: Url, + headers: HeaderMap, + turn_state: Option>>, +) -> Result<(WsStream, bool), ApiError> { let mut request = url - .clone() + .as_str() .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) + let (stream, response) = tokio_tungstenite::connect_async(request) .await .map_err(|err| map_ws_error(err, &url))?; - Ok(stream) + let reasoning_included = response.headers().contains_key(X_REASONING_INCLUDED_HEADER); + if let Some(turn_state) = turn_state + && let Some(header_value) = response + .headers() + .get(X_CODEX_TURN_STATE_HEADER) + .and_then(|value| value.to_str().ok()) + { + let _ = turn_state.set(header_value.to_string()); + } + Ok((stream, reasoning_included)) } fn map_ws_error(err: WsError, url: &Url) -> ApiError { @@ -182,7 +209,7 @@ async fn run_websocket_response_stream( } }; - if let Err(err) = ws_stream.send(Message::Text(request_text)).await { + if let Err(err) = ws_stream.send(Message::Text(request_text.into())).await { return Err(ApiError::Stream(format!( "failed to send websocket request: {err}" ))); diff --git a/codex-rs/codex-api/src/endpoint/streaming.rs b/codex-rs/codex-api/src/endpoint/streaming.rs index de180845e8..15d4c077a0 100644 --- a/codex-rs/codex-api/src/endpoint/streaming.rs +++ b/codex-rs/codex-api/src/endpoint/streaming.rs @@ -13,6 +13,7 @@ use http::HeaderMap; use http::Method; use serde_json::Value; use std::sync::Arc; +use std::sync::OnceLock; use std::time::Duration; pub(crate) struct StreamingClient { @@ -23,6 +24,13 @@ pub(crate) struct StreamingClient { sse_telemetry: Option>, } +type StreamSpawner = fn( + StreamResponse, + Duration, + Option>, + Option>>, +) -> ResponseStream; + impl StreamingClient { pub(crate) fn new(transport: T, provider: Provider, auth: A) -> Self { Self { @@ -54,7 +62,8 @@ impl StreamingClient { body: Value, extra_headers: HeaderMap, compression: RequestCompression, - spawner: fn(StreamResponse, Duration, Option>) -> ResponseStream, + spawner: StreamSpawner, + turn_state: Option>>, ) -> Result { let builder = || { let mut req = self.provider.build_request(Method::POST, path); @@ -80,6 +89,7 @@ impl StreamingClient { stream_response, self.provider.stream_idle_timeout, self.sse_telemetry.clone(), + turn_state, )) } } diff --git a/codex-rs/codex-api/src/error.rs b/codex-rs/codex-api/src/error.rs index 60118e8723..e7fbd14543 100644 --- a/codex-rs/codex-api/src/error.rs +++ b/codex-rs/codex-api/src/error.rs @@ -25,6 +25,8 @@ pub enum ApiError { }, #[error("rate limit: {0}")] RateLimit(String), + #[error("invalid request: {message}")] + InvalidRequest { message: String }, } impl From for ApiError { diff --git a/codex-rs/codex-api/src/sse/chat.rs b/codex-rs/codex-api/src/sse/chat.rs index dec35890b7..afd7c8852b 100644 --- a/codex-rs/codex-api/src/sse/chat.rs +++ b/codex-rs/codex-api/src/sse/chat.rs @@ -11,6 +11,8 @@ use futures::Stream; use futures::StreamExt; use std::collections::HashMap; use std::collections::HashSet; +use std::sync::Arc; +use std::sync::OnceLock; use std::time::Duration; use tokio::sync::mpsc; use tokio::time::Instant; @@ -21,7 +23,8 @@ use tracing::trace; pub(crate) fn spawn_chat_stream( stream_response: StreamResponse, idle_timeout: Duration, - telemetry: Option>, + telemetry: Option>, + _turn_state: Option>>, ) -> ResponseStream { let (tx_event, rx_event) = mpsc::channel::>(1600); tokio::spawn(async move { diff --git a/codex-rs/codex-api/src/sse/responses.rs b/codex-rs/codex-api/src/sse/responses.rs index 06f74db0ab..f23975f8dd 100644 --- a/codex-rs/codex-api/src/sse/responses.rs +++ b/codex-rs/codex-api/src/sse/responses.rs @@ -16,6 +16,7 @@ use serde_json::Value; use std::io::BufRead; use std::path::Path; use std::sync::Arc; +use std::sync::OnceLock; use std::time::Duration; use tokio::sync::mpsc; use tokio::time::Instant; @@ -24,6 +25,8 @@ use tokio_util::io::ReaderStream; use tracing::debug; use tracing::trace; +const X_REASONING_INCLUDED_HEADER: &str = "x-reasoning-included"; + /// Streams SSE events from an on-disk fixture for tests. pub fn stream_from_fixture( path: impl AsRef, @@ -49,6 +52,7 @@ pub fn spawn_response_stream( stream_response: StreamResponse, idle_timeout: Duration, telemetry: Option>, + turn_state: Option>>, ) -> ResponseStream { let rate_limits = parse_rate_limit(&stream_response.headers); let models_etag = stream_response @@ -56,6 +60,18 @@ pub fn spawn_response_stream( .get("X-Models-Etag") .and_then(|v| v.to_str().ok()) .map(ToString::to_string); + let reasoning_included = stream_response + .headers + .get(X_REASONING_INCLUDED_HEADER) + .is_some(); + if let Some(turn_state) = turn_state.as_ref() + && let Some(header_value) = stream_response + .headers + .get("x-codex-turn-state") + .and_then(|v| v.to_str().ok()) + { + let _ = turn_state.set(header_value.to_string()); + } let (tx_event, rx_event) = mpsc::channel::>(1600); tokio::spawn(async move { if let Some(snapshot) = rate_limits { @@ -64,6 +80,11 @@ pub fn spawn_response_stream( if let Some(etag) = models_etag { let _ = tx_event.send(Ok(ResponseEvent::ModelsEtag(etag))).await; } + if reasoning_included { + let _ = tx_event + .send(Ok(ResponseEvent::ServerReasoningIncluded(true))) + .await; + } process_sse(stream_response.bytes, tx_event, idle_timeout, telemetry).await; }); @@ -207,6 +228,11 @@ pub fn process_responses_event( response_error = ApiError::QuotaExceeded; } else if is_usage_not_included(&error) { response_error = ApiError::UsageNotIncluded; + } else if is_invalid_prompt_error(&error) { + let message = error + .message + .unwrap_or_else(|| "Invalid request.".to_string()); + response_error = ApiError::InvalidRequest { message }; } else { let delay = try_parse_retry_after(&error); let message = error.message.unwrap_or_default(); @@ -386,6 +412,10 @@ fn is_usage_not_included(error: &Error) -> bool { error.code.as_deref() == Some("usage_not_included") } +fn is_invalid_prompt_error(error: &Error) -> bool { + error.code.as_deref() == Some("invalid_prompt") +} + fn rate_limit_regex() -> &'static regex_lite::Regex { static RE: std::sync::OnceLock = std::sync::OnceLock::new(); #[expect(clippy::unwrap_used)] @@ -701,6 +731,27 @@ mod tests { assert_matches!(events[0], Err(ApiError::QuotaExceeded)); } + #[tokio::test] + async fn invalid_prompt_without_type_is_invalid_request() { + let raw_error = r#"{"type":"response.failed","sequence_number":3,"response":{"id":"resp_invalid_prompt_no_type","object":"response","created_at":1759771628,"status":"failed","background":false,"error":{"code":"invalid_prompt","message":"Invalid prompt: we've limited access to this content for safety reasons."},"incomplete_details":null}}"#; + + let sse1 = format!("event: response.failed\ndata: {raw_error}\n\n"); + + let events = collect_events(&[sse1.as_bytes()]).await; + + assert_eq!(events.len(), 1); + + match &events[0] { + Err(ApiError::InvalidRequest { message }) => { + assert_eq!( + message, + "Invalid prompt: we've limited access to this content for safety reasons." + ); + } + other => panic!("unexpected event: {other:?}"), + } + } + #[tokio::test] async fn table_driven_event_kinds() { struct TestCase { diff --git a/codex-rs/codex-api/tests/clients.rs b/codex-rs/codex-api/tests/clients.rs index 70af9fe829..1a513bdca4 100644 --- a/codex-rs/codex-api/tests/clients.rs +++ b/codex-rs/codex-api/tests/clients.rs @@ -231,7 +231,7 @@ async fn responses_client_uses_responses_path_for_responses_wire() -> Result<()> let body = serde_json::json!({ "echo": true }); let _stream = client - .stream(body, HeaderMap::new(), Compression::None) + .stream(body, HeaderMap::new(), Compression::None, None) .await?; let requests = state.take_stream_requests(); @@ -247,7 +247,7 @@ async fn responses_client_uses_chat_path_for_chat_wire() -> Result<()> { let body = serde_json::json!({ "echo": true }); let _stream = client - .stream(body, HeaderMap::new(), Compression::None) + .stream(body, HeaderMap::new(), Compression::None, None) .await?; let requests = state.take_stream_requests(); @@ -264,7 +264,7 @@ async fn streaming_client_adds_auth_headers() -> Result<()> { let body = serde_json::json!({ "model": "gpt-test" }); let _stream = client - .stream(body, HeaderMap::new(), Compression::None) + .stream(body, HeaderMap::new(), Compression::None, None) .await?; let requests = state.take_stream_requests(); diff --git a/codex-rs/codex-api/tests/sse_end_to_end.rs b/codex-rs/codex-api/tests/sse_end_to_end.rs index f324cc7480..eb9d7993f8 100644 --- a/codex-rs/codex-api/tests/sse_end_to_end.rs +++ b/codex-rs/codex-api/tests/sse_end_to_end.rs @@ -129,6 +129,7 @@ async fn responses_stream_parses_items_and_completed_end_to_end() -> Result<()> serde_json::json!({"echo": true}), HeaderMap::new(), Compression::None, + None, ) .await?; @@ -198,6 +199,7 @@ async fn responses_stream_aggregates_output_text_deltas() -> Result<()> { serde_json::json!({"echo": true}), HeaderMap::new(), Compression::None, + None, ) .await?; diff --git a/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs b/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs index 0f5caf52ff..daf8e6af39 100644 --- a/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs +++ b/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs @@ -42,9 +42,12 @@ impl RateLimitStatusPayload { } } -#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)] +#[derive( + Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize, Default, +)] pub enum PlanType { #[serde(rename = "guest")] + #[default] Guest, #[serde(rename = "free")] Free, @@ -71,9 +74,3 @@ pub enum PlanType { #[serde(rename = "edu")] Edu, } - -impl Default for PlanType { - fn default() -> PlanType { - Self::Guest - } -} diff --git a/codex-rs/core/BUILD.bazel b/codex-rs/core/BUILD.bazel index 37a3173f9a..f1649b36c7 100644 --- a/codex-rs/core/BUILD.bazel +++ b/codex-rs/core/BUILD.bazel @@ -18,6 +18,7 @@ codex_rust_crate( ), integration_compile_data_extra = [ "//codex-rs/apply-patch:apply_patch_tool_instructions.md", + "models.json", "prompt.md", ], test_data_extra = [ diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 10b635b747..8ee2848a98 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -18,7 +18,7 @@ workspace = true [dependencies] anyhow = { workspace = true } -arc-swap = "1.7.1" +arc-swap = "1.8.0" async-channel = { workspace = true } async-trait = { workspace = true } base64 = { workspace = true } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 37a0cce703..ed701bf016 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -53,14 +53,6 @@ "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" }, @@ -75,9 +67,15 @@ "apply_patch_freeform": { "type": "boolean" }, + "child_agents_md": { + "type": "boolean" + }, "collab": { "type": "boolean" }, + "collaboration_modes": { + "type": "boolean" + }, "elevated_windows_sandbox": { "type": "boolean" }, @@ -99,9 +97,6 @@ "experimental_windows_sandbox": { "type": "boolean" }, - "child_agents_md": { - "type": "boolean" - }, "include_apply_patch_tool": { "type": "boolean" }, @@ -237,6 +232,22 @@ "type": "integer", "format": "int64" }, + "model_instructions": { + "description": "Optional path to a file containing model instructions.", + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ] + }, + "model_personality": { + "description": "EXPERIMENTAL Optionally specify a personality for the model", + "allOf": [ + { + "$ref": "#/definitions/Personality" + } + ] + }, "model_provider": { "description": "Provider to use from the model_providers map.", "type": "string" @@ -373,6 +384,14 @@ "description": "When set to `true`, `AgentReasoningRawContentEvent` events will be shown in the UI/output. Defaults to `false`.", "type": "boolean" }, + "skills": { + "description": "User-level skill config entries keyed by SKILL.md path.", + "allOf": [ + { + "$ref": "#/definitions/SkillsConfig" + } + ] + }, "tool_output_token_limit": { "description": "Token budget applied when storing tool/function outputs in the context manager.", "type": "integer", @@ -526,9 +545,6 @@ "experimental_compact_prompt_file": { "$ref": "#/definitions/AbsolutePathBuf" }, - "experimental_instructions_file": { - "$ref": "#/definitions/AbsolutePathBuf" - }, "experimental_use_freeform_apply_patch": { "type": "boolean" }, @@ -543,9 +559,15 @@ "apply_patch_freeform": { "type": "boolean" }, + "child_agents_md": { + "type": "boolean" + }, "collab": { "type": "boolean" }, + "collaboration_modes": { + "type": "boolean" + }, "elevated_windows_sandbox": { "type": "boolean" }, @@ -567,9 +589,6 @@ "experimental_windows_sandbox": { "type": "boolean" }, - "child_agents_md": { - "type": "boolean" - }, "include_apply_patch_tool": { "type": "boolean" }, @@ -618,6 +637,17 @@ "model": { "type": "string" }, + "model_instructions": { + "description": "Optional path to a file containing model instructions.", + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ] + }, + "model_personality": { + "$ref": "#/definitions/Personality" + }, "model_provider": { "description": "The key in the `model_providers` map identifying the [`ModelProviderInfo`] to use.", "type": "string" @@ -1037,6 +1067,13 @@ }, "additionalProperties": false }, + "Personality": { + "type": "string", + "enum": [ + "friendly", + "pragmatic" + ] + }, "ProjectConfig": { "type": "object", "properties": { @@ -1288,6 +1325,34 @@ }, "additionalProperties": false }, + "SkillConfig": { + "type": "object", + "required": [ + "enabled", + "path" + ], + "properties": { + "enabled": { + "type": "boolean" + }, + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + } + }, + "additionalProperties": false + }, + "SkillsConfig": { + "type": "object", + "properties": { + "config": { + "type": "array", + "items": { + "$ref": "#/definitions/SkillConfig" + } + } + }, + "additionalProperties": false + }, "ToolsToml": { "type": "object", "properties": { diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 7083081dc6..4467494fcf 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -58,7 +58,7 @@ impl AgentControl { Op::UserInput { items: vec![UserInput::Text { text: prompt, - // Plain text conversion has no UI element ranges. + // Agent control prompts are plain text with no UI text elements. text_elements: Vec::new(), }], final_output_json_schema: None, @@ -85,7 +85,6 @@ impl AgentControl { result } - #[allow(dead_code)] // Will be used for collab tools. /// Fetch the last known status for `agent_id`, returning `NotFound` when unavailable. pub(crate) async fn get_status(&self, agent_id: ThreadId) -> AgentStatus { let Ok(state) = self.upgrade() else { diff --git a/codex-rs/core/src/api_bridge.rs b/codex-rs/core/src/api_bridge.rs index 19bd8d5ecb..79ca839816 100644 --- a/codex-rs/core/src/api_bridge.rs +++ b/codex-rs/core/src/api_bridge.rs @@ -28,6 +28,7 @@ pub(crate) fn map_api_error(err: ApiError) -> CodexErr { url: None, request_id: None, }), + ApiError::InvalidRequest { message } => CodexErr::InvalidRequest(message), ApiError::Transport(transport) => match transport { TransportError::Http { status, diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 326d81e0e9..f0f2b125fd 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -1,4 +1,5 @@ use std::sync::Arc; +use std::sync::OnceLock; use crate::api_bridge::CoreAuthProvider; use crate::api_bridge::auth_provider_from_auth; @@ -66,6 +67,7 @@ use crate::tools::spec::create_tools_json_for_chat_completions_api; use crate::tools::spec::create_tools_json_for_responses_api; pub const WEB_SEARCH_ELIGIBLE_HEADER: &str = "x-oai-web-search-eligible"; +pub const X_CODEX_TURN_STATE_HEADER: &str = "x-codex-turn-state"; #[derive(Debug)] struct ModelClientState { @@ -89,6 +91,17 @@ pub struct ModelClientSession { state: Arc, connection: Option, websocket_last_items: Vec, + /// Turn state for sticky routing. + /// + /// This is an `OnceLock` that stores the turn state value received from the server + /// on turn start via the `x-codex-turn-state` response header. Once set, this value + /// should be sent back to the server in the `x-codex-turn-state` request header for + /// all subsequent requests within the same turn to maintain sticky routing. + /// + /// This is a contract between the client and server: we receive it at turn start, + /// keep sending it unchanged between turn requests (e.g., for retries, incremental + /// appends, or continuation requests), and must not send it between different turns. + turn_state: Arc>, } #[allow(clippy::too_many_arguments)] @@ -124,6 +137,7 @@ impl ModelClient { state: Arc::clone(&self.state), connection: None, websocket_last_items: Vec::new(), + turn_state: Arc::new(OnceLock::new()), } } } @@ -203,9 +217,7 @@ impl ModelClient { 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 instructions = prompt.base_instructions.text.clone(); let payload = ApiCompactionInput { model: &self.state.model_info.slug, input: &prompt.input, @@ -226,7 +238,6 @@ impl ModelClient { extra_headers.insert("x-openai-subagent", val); } } - client .compact_input(&payload, extra_headers) .await @@ -263,8 +274,7 @@ impl ModelClientSession { } 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 instructions = prompt.base_instructions.text.clone(); let tools_json: Vec = create_tools_json_for_responses_api(&prompt.tools)?; Ok(build_api_prompt(prompt, instructions, tools_json)) } @@ -322,8 +332,9 @@ impl ModelClientSession { store_override: None, conversation_id: Some(conversation_id), session_source: Some(self.state.session_source.clone()), - extra_headers: build_responses_headers(&self.state.config), + extra_headers: build_responses_headers(&self.state.config, Some(&self.turn_state)), compression, + turn_state: Some(Arc::clone(&self.turn_state)), } } @@ -397,7 +408,7 @@ impl ModelClientSession { headers.extend(build_conversation_headers(options.conversation_id.clone())); let new_conn: ApiWebSocketConnection = ApiWebSocketResponsesClient::new(api_provider, api_auth) - .connect(headers) + .connect(headers, options.turn_state.clone()) .await?; self.connection = Some(new_conn); } @@ -434,8 +445,7 @@ impl ModelClientSession { } 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 instructions = prompt.base_instructions.text.clone(); 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.state.conversation_id.to_string(); @@ -638,7 +648,10 @@ fn beta_feature_headers(config: &Config) -> ApiHeaderMap { headers } -fn build_responses_headers(config: &Config) -> ApiHeaderMap { +fn build_responses_headers( + config: &Config, + turn_state: Option<&Arc>>, +) -> ApiHeaderMap { let mut headers = beta_feature_headers(config); headers.insert( WEB_SEARCH_ELIGIBLE_HEADER, @@ -650,6 +663,12 @@ fn build_responses_headers(config: &Config) -> ApiHeaderMap { }, ), ); + if let Some(turn_state) = turn_state + && let Some(state) = turn_state.get() + && let Ok(header_value) = HeaderValue::from_str(state) + { + headers.insert(X_CODEX_TURN_STATE_HEADER, header_value); + } headers } diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index 7d7cabcfa6..2614ce83ef 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -1,12 +1,12 @@ use crate::client_common::tools::ToolSpec; +use crate::config::types::Personality; use crate::error::Result; pub use codex_api::common::ResponseEvent; +use codex_protocol::models::BaseInstructions; use codex_protocol::models::ResponseItem; -use codex_protocol::openai_models::ModelInfo; use futures::Stream; use serde::Deserialize; use serde_json::Value; -use std::borrow::Cow; use std::collections::HashSet; use std::pin::Pin; use std::task::Context; @@ -34,22 +34,16 @@ pub struct Prompt { /// Whether parallel tool calls are permitted for this prompt. pub(crate) parallel_tool_calls: bool, - /// Optional override for the built-in BASE_INSTRUCTIONS. - pub base_instructions_override: Option, + pub base_instructions: BaseInstructions, + + /// Optionally specify the personality of the model. + pub personality: Option, /// Optional the output schema for the model's response. pub output_schema: Option, } impl Prompt { - pub(crate) fn get_full_instructions<'a>(&'a self, model: &'a ModelInfo) -> Cow<'a, str> { - Cow::Borrowed( - self.base_instructions_override - .as_deref() - .unwrap_or(model.base_instructions.as_str()), - ) - } - pub(crate) fn get_formatted_input(&self) -> Vec { let mut input = self.input.clone(); @@ -245,76 +239,8 @@ mod tests { use codex_api::create_text_param_for_request; use pretty_assertions::assert_eq; - use crate::config::test_config; - use crate::models_manager::manager::ModelsManager; - use super::*; - struct InstructionsTestCase { - pub slug: &'static str, - pub expects_apply_patch_instructions: bool, - } - #[test] - fn get_full_instructions_no_user_content() { - let prompt = Prompt { - ..Default::default() - }; - let prompt_with_apply_patch_instructions = - include_str!("../prompt_with_apply_patch_instructions.md"); - let test_cases = vec![ - InstructionsTestCase { - slug: "gpt-3.5", - expects_apply_patch_instructions: true, - }, - InstructionsTestCase { - slug: "gpt-4.1", - expects_apply_patch_instructions: true, - }, - InstructionsTestCase { - slug: "gpt-4o", - expects_apply_patch_instructions: true, - }, - InstructionsTestCase { - slug: "gpt-5", - expects_apply_patch_instructions: true, - }, - InstructionsTestCase { - slug: "gpt-5.1", - expects_apply_patch_instructions: false, - }, - InstructionsTestCase { - slug: "codex-mini-latest", - expects_apply_patch_instructions: true, - }, - InstructionsTestCase { - slug: "gpt-oss:120b", - expects_apply_patch_instructions: false, - }, - InstructionsTestCase { - slug: "gpt-5.1-codex", - expects_apply_patch_instructions: false, - }, - InstructionsTestCase { - slug: "gpt-5.1-codex-max", - expects_apply_patch_instructions: false, - }, - ]; - for test_case in test_cases { - let config = test_config(); - let model_info = ModelsManager::construct_model_info_offline(test_case.slug, &config); - if test_case.expects_apply_patch_instructions { - assert_eq!( - model_info.base_instructions.as_str(), - prompt_with_apply_patch_instructions - ); - } - - let expected = model_info.base_instructions.as_str(); - let full = prompt.get_full_instructions(&model_info); - assert_eq!(full, expected); - } - } - #[test] fn serializes_text_verbosity_when_set() { let input: Vec = vec![]; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 06000a345c..557d7ebd19 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -12,7 +12,6 @@ use crate::SandboxState; use crate::agent::AgentControl; use crate::agent::AgentStatus; use crate::agent::agent_status_from_event; -use crate::client_common::REVIEW_PROMPT; use crate::compact; use crate::compact::run_inline_auto_compact_task; use crate::compact::should_use_remote_compact_task; @@ -34,9 +33,11 @@ use async_channel::Receiver; use async_channel::Sender; use codex_protocol::ThreadId; use codex_protocol::approvals::ExecPolicyAmendment; +use codex_protocol::config_types::Settings; use codex_protocol::config_types::WebSearchMode; use codex_protocol::items::TurnItem; use codex_protocol::items::UserMessageItem; +use codex_protocol::models::BaseInstructions; use codex_protocol::openai_models::ModelInfo; use codex_protocol::protocol::FileChange; use codex_protocol::protocol::HasLegacyEvent; @@ -49,6 +50,8 @@ use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnStartedEvent; +use codex_protocol::request_user_input::RequestUserInputArgs; +use codex_protocol::request_user_input::RequestUserInputResponse; use codex_rmcp_client::ElicitationResponse; use codex_rmcp_client::OAuthCredentialsStoreMode; use futures::future::BoxFuture; @@ -98,6 +101,7 @@ use crate::error::Result as CodexResult; use crate::exec::StreamOutput; use crate::exec_policy::ExecPolicyUpdateError; use crate::feedback_tags; +use crate::instructions::UserInstructions; use crate::mcp::auth::compute_auth_statuses; use crate::mcp_connection_manager::McpConnectionManager; use crate::model_provider_info::CHAT_WIRE_API_DEPRECATION_SUMMARY; @@ -117,6 +121,7 @@ use crate::protocol::Op; use crate::protocol::RateLimitSnapshot; use crate::protocol::ReasoningContentDeltaEvent; use crate::protocol::ReasoningRawContentDeltaEvent; +use crate::protocol::RequestUserInputEvent; use crate::protocol::ReviewDecision; use crate::protocol::SandboxPolicy; use crate::protocol::SessionConfiguredEvent; @@ -155,17 +160,16 @@ 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::UserInstructions; use crate::user_notification::UserNotification; use crate::util::backoff; use codex_async_utils::OrCancelExt; use codex_otel::OtelManager; +use codex_protocol::config_types::CollaborationMode; 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; use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::InitialHistory; use codex_protocol::user_input::UserInput; @@ -250,7 +254,8 @@ impl Codex { ); } - let user_instructions = get_user_instructions(&config, Some(&loaded_skills.skills)).await; + let enabled_skills = loaded_skills.enabled_skills(); + let user_instructions = get_user_instructions(&config, Some(&enabled_skills)).await; let exec_policy = ExecPolicyManager::load(&config.features, &config.config_layer_stack) .await @@ -270,14 +275,32 @@ impl Codex { crate::models_manager::manager::RefreshStrategy::OnlineIfUncached, ) .await; + + // Resolve base instructions for the session. Priority order: + // 1. config.base_instructions override + // 2. conversation history => session_meta.base_instructions + // 3. base_intructions for current model + let model_info = models_manager.get_model_info(model.as_str(), &config).await; + let base_instructions = config + .base_instructions + .clone() + .or_else(|| conversation_history.get_base_instructions().map(|s| s.text)) + .unwrap_or_else(|| model_info.base_instructions.clone()); + + // TODO (aibrahim): Consolidate config.model and config.model_reasoning_effort into config.collaboration_mode + // to avoid extracting these fields separately and constructing CollaborationMode here. + let collaboration_mode = CollaborationMode::Custom(Settings { + model: model.clone(), + reasoning_effort: config.model_reasoning_effort, + developer_instructions: None, + }); let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), - model: model.clone(), - model_reasoning_effort: config.model_reasoning_effort, + collaboration_mode, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), user_instructions, - base_instructions: config.base_instructions.clone(), + base_instructions, compact_prompt: config.compact_prompt.clone(), approval_policy: config.approval_policy.clone(), sandbox_policy: config.sandbox_policy.clone(), @@ -389,7 +412,6 @@ pub(crate) struct TurnContext { /// instead of `std::env::current_dir()`. pub(crate) cwd: PathBuf, pub(crate) developer_instructions: Option, - pub(crate) base_instructions: Option, pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, pub(crate) approval_policy: AskForApproval, @@ -422,10 +444,7 @@ pub(crate) struct SessionConfiguration { /// Provider identifier ("openai", "openrouter", ...). provider: ModelProviderInfo, - /// If not specified, server will use its default model. - model: String, - - model_reasoning_effort: Option, + collaboration_mode: CollaborationMode, model_reasoning_summary: ReasoningSummaryConfig, /// Developer instructions that supplement the base instructions. @@ -434,8 +453,8 @@ pub(crate) struct SessionConfiguration { /// Model instructions that are appended to the base instructions. user_instructions: Option, - /// Base instructions override. - base_instructions: Option, + /// Base instructions for the session. + base_instructions: String, /// Compact prompt override. compact_prompt: Option, @@ -463,11 +482,8 @@ pub(crate) struct SessionConfiguration { impl SessionConfiguration { pub(crate) fn apply(&self, updates: &SessionSettingsUpdate) -> ConstraintResult { let mut next_configuration = self.clone(); - if let Some(model) = updates.model.clone() { - next_configuration.model = model; - } - if let Some(effort) = updates.reasoning_effort { - next_configuration.model_reasoning_effort = effort; + if let Some(collaboration_mode) = updates.collaboration_mode.clone() { + next_configuration.collaboration_mode = collaboration_mode; } if let Some(summary) = updates.reasoning_summary { next_configuration.model_reasoning_summary = summary; @@ -490,8 +506,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) cwd: Option, pub(crate) approval_policy: Option, pub(crate) sandbox_policy: Option, - pub(crate) model: Option, - pub(crate) reasoning_effort: Option>, + pub(crate) collaboration_mode: Option, pub(crate) reasoning_summary: Option, pub(crate) final_output_json_schema: Option>, } @@ -502,7 +517,8 @@ impl Session { // todo(aibrahim): store this state somewhere else so we don't need to mut config let config = session_configuration.original_config_do_not_use.clone(); let mut per_turn_config = (*config).clone(); - per_turn_config.model_reasoning_effort = session_configuration.model_reasoning_effort; + per_turn_config.model_reasoning_effort = + session_configuration.collaboration_mode.reasoning_effort(); per_turn_config.model_reasoning_summary = session_configuration.model_reasoning_summary; per_turn_config.features = config.features.clone(); per_turn_config @@ -520,10 +536,9 @@ impl Session { sub_id: String, ) -> TurnContext { let otel_manager = otel_manager.clone().with_model( - session_configuration.model.as_str(), + session_configuration.collaboration_mode.model(), model_info.slug.as_str(), ); - let per_turn_config = Arc::new(per_turn_config); let client = ModelClient::new( per_turn_config.clone(), @@ -531,7 +546,7 @@ impl Session { model_info.clone(), otel_manager, provider, - session_configuration.model_reasoning_effort, + session_configuration.collaboration_mode.reasoning_effort(), session_configuration.model_reasoning_summary, conversation_id, session_configuration.session_source.clone(), @@ -548,7 +563,6 @@ impl Session { client, cwd: session_configuration.cwd.clone(), developer_instructions: session_configuration.developer_instructions.clone(), - base_instructions: session_configuration.base_instructions.clone(), compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration.user_instructions.clone(), approval_policy: session_configuration.approval_policy.value(), @@ -579,7 +593,8 @@ impl Session { ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", - session_configuration.model, session_configuration.provider + session_configuration.collaboration_mode.model(), + session_configuration.provider ); if !session_configuration.cwd.is_absolute() { return Err(anyhow::anyhow!( @@ -588,6 +603,8 @@ impl Session { )); } + let forked_from_id = initial_history.forked_from_id(); + let (conversation_id, rollout_params) = match &initial_history { InitialHistory::New | InitialHistory::Forked(_) => { let conversation_id = ThreadId::default(); @@ -595,8 +612,11 @@ impl Session { conversation_id, RolloutRecorderParams::new( conversation_id, - session_configuration.user_instructions.clone(), + forked_from_id, session_source, + BaseInstructions { + text: session_configuration.base_instructions.clone(), + }, ), ) } @@ -646,14 +666,27 @@ impl Session { msg: EventMsg::DeprecationNotice(DeprecationNoticeEvent { summary, details }), }); } + if crate::config::uses_deprecated_instructions_file(&config.config_layer_stack) { + post_session_configured_events.push(Event { + id: INITIAL_SUBMIT_ID.to_owned(), + msg: EventMsg::DeprecationNotice(DeprecationNoticeEvent { + summary: "`experimental_instructions_file` is deprecated and ignored. Use `model_instructions` instead." + .to_string(), + details: Some( + "Move the setting to `model_instructions` in config.toml (or under a profile) to load instructions from a file." + .to_string(), + ), + }), + }); + } maybe_push_chat_wire_api_deprecation(&config, &mut post_session_configured_events); let auth = auth_manager.auth().await; let auth = auth.as_ref(); let otel_manager = OtelManager::new( conversation_id, - session_configuration.model.as_str(), - session_configuration.model.as_str(), + session_configuration.collaboration_mode.model(), + session_configuration.collaboration_mode.model(), auth.and_then(CodexAuth::get_account_id), auth.and_then(CodexAuth::get_account_email), auth.map(|a| a.mode), @@ -677,7 +710,7 @@ impl Session { otel_manager.conversation_starts( config.model_provider.name.as_str(), - config.model_reasoning_effort, + session_configuration.collaboration_mode.reasoning_effort(), config.model_reasoning_summary, config.model_context_window, config.model_auto_compact_token_limit, @@ -690,10 +723,18 @@ impl Session { let mut default_shell = shell::default_user_shell(); // Create the mutable state for the Session. if config.features.enabled(Feature::ShellSnapshot) { + let timer = otel_manager.start_timer("codex.shell_snapshot.duration_ms", &[]); default_shell.shell_snapshot = ShellSnapshot::try_new(&config.codex_home, conversation_id, &default_shell) .await .map(Arc::new); + let success = if default_shell.shell_snapshot.is_some() { + "true" + } else { + "false" + }; + let _ = timer.map(|timer| timer.record(&[("success", success)])); + otel_manager.counter("codex.shell_snapshot", 1, &[("success", success)]) } let state = SessionState::new(session_configuration.clone()); @@ -733,12 +774,13 @@ impl Session { id: INITIAL_SUBMIT_ID.to_owned(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: conversation_id, - model: session_configuration.model.clone(), + forked_from_id, + model: session_configuration.collaboration_mode.model().to_string(), model_provider_id: config.model_provider_id.clone(), approval_policy: session_configuration.approval_policy.value(), sandbox_policy: session_configuration.sandbox_policy.get().clone(), cwd: session_configuration.cwd.clone(), - reasoning_effort: session_configuration.model_reasoning_effort, + reasoning_effort: session_configuration.collaboration_mode.reasoning_effort(), history_log_id, history_entry_count, initial_messages, @@ -805,7 +847,14 @@ impl Session { async fn get_total_token_usage(&self) -> i64 { let state = self.state.lock().await; - state.get_total_token_usage() + state.get_total_token_usage(state.server_reasoning_included()) + } + + pub(crate) async fn get_base_instructions(&self) -> BaseInstructions { + let state = self.state.lock().await; + BaseInstructions { + text: state.session_configuration.base_instructions.clone(), + } } async fn record_initial_history(&self, conversation_history: InitialHistory) { @@ -813,7 +862,7 @@ impl Session { match conversation_history { InitialHistory::New => { // Build and record initial items (user instructions + environment context) - let items = self.build_initial_context(&turn_context); + let items = self.build_initial_context(&turn_context).await; self.record_conversation_items(&turn_context, &items).await; // Ensure initial items are visible to immediate readers (e.g., tests, forks). self.flush_rollout().await; @@ -851,8 +900,9 @@ impl Session { } // Always add response items to conversation history - let reconstructed_history = - self.reconstruct_history_from_rollout(&turn_context, &rollout_items); + let reconstructed_history = self + .reconstruct_history_from_rollout(&turn_context, &rollout_items) + .await; if !reconstructed_history.is_empty() { self.record_into_history(&reconstructed_history, &turn_context) .await; @@ -871,7 +921,7 @@ impl Session { } // Append the current session's initial context after the reconstructed history. - let initial_context = self.build_initial_context(&turn_context); + let initial_context = self.build_initial_context(&turn_context).await; self.record_conversation_items(&turn_context, &initial_context) .await; // Flush after seeding history and any persisted rollout copy. @@ -974,7 +1024,10 @@ impl Session { let model_info = self .services .models_manager - .get_model_info(session_configuration.model.as_str(), &per_turn_config) + .get_model_info( + session_configuration.collaboration_mode.model(), + &per_turn_config, + ) .await; let mut turn_context: TurnContext = Self::make_turn_context( Some(Arc::clone(&self.services.auth_manager)), @@ -1056,6 +1109,50 @@ impl Session { ) } + fn build_collaboration_mode_update_item( + &self, + previous_collaboration_mode: &CollaborationMode, + next_collaboration_mode: Option<&CollaborationMode>, + ) -> Option { + if let Some(next_mode) = next_collaboration_mode { + if previous_collaboration_mode == next_mode { + return None; + } + // If the next mode has empty developer instructions, this returns None and we emit no + // update, so prior collaboration instructions remain in the prompt history. + Some(DeveloperInstructions::from_collaboration_mode(next_mode)?.into()) + } else { + None + } + } + + fn build_settings_update_items( + &self, + previous_context: Option<&Arc>, + current_context: &TurnContext, + previous_collaboration_mode: &CollaborationMode, + next_collaboration_mode: Option<&CollaborationMode>, + ) -> Vec { + let mut update_items = Vec::new(); + if let Some(env_item) = + self.build_environment_update_item(previous_context, current_context) + { + update_items.push(env_item); + } + if let Some(permissions_item) = + self.build_permissions_update_item(previous_context, current_context) + { + update_items.push(permissions_item); + } + if let Some(collaboration_mode_item) = self.build_collaboration_mode_update_item( + previous_collaboration_mode, + next_collaboration_mode, + ) { + update_items.push(collaboration_mode_item); + } + update_items + } + /// 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(); @@ -1247,6 +1344,63 @@ impl Session { rx_approve } + pub async fn request_user_input( + &self, + turn_context: &TurnContext, + call_id: String, + args: RequestUserInputArgs, + ) -> Option { + let sub_id = turn_context.sub_id.clone(); + let (tx_response, rx_response) = oneshot::channel(); + let event_id = sub_id.clone(); + let prev_entry = { + let mut active = self.active_turn.lock().await; + match active.as_mut() { + Some(at) => { + let mut ts = at.turn_state.lock().await; + ts.insert_pending_user_input(sub_id, tx_response) + } + None => None, + } + }; + if prev_entry.is_some() { + warn!("Overwriting existing pending user input for sub_id: {event_id}"); + } + + let event = EventMsg::RequestUserInput(RequestUserInputEvent { + call_id, + turn_id: turn_context.sub_id.clone(), + questions: args.questions, + }); + self.send_event(turn_context, event).await; + rx_response.await.ok() + } + + pub async fn notify_user_input_response( + &self, + sub_id: &str, + response: RequestUserInputResponse, + ) { + let entry = { + let mut active = self.active_turn.lock().await; + match active.as_mut() { + Some(at) => { + let mut ts = at.turn_state.lock().await; + ts.remove_pending_user_input(sub_id) + } + None => None, + } + }; + match entry { + Some(tx_response) => { + tx_response.send(response).ok(); + } + None => { + warn!("No pending user input found for sub_id: {sub_id}"); + } + } + } + pub async fn notify_approval(&self, sub_id: &str, decision: ReviewDecision) { let entry = { let mut active = self.active_turn.lock().await; @@ -1294,7 +1448,7 @@ impl Session { self.send_raw_response_items(turn_context, items).await; } - fn reconstruct_history_from_rollout( + async fn reconstruct_history_from_rollout( &self, turn_context: &TurnContext, rollout_items: &[RolloutItem], @@ -1314,7 +1468,7 @@ impl Session { } else { let user_messages = collect_user_messages(history.raw_items()); let rebuilt = compact::build_compacted_history( - self.build_initial_context(turn_context), + self.build_initial_context(turn_context).await, &user_messages, &compacted.message, ); @@ -1341,6 +1495,9 @@ impl Session { } pub(crate) async fn record_model_warning(&self, message: impl Into, ctx: &TurnContext) { + self.services + .otel_manager + .counter("codex.model_warning", 1, &[]); let item = ResponseItem::Message { id: None, role: "user".to_string(), @@ -1384,7 +1541,10 @@ impl Session { } } - pub(crate) fn build_initial_context(&self, turn_context: &TurnContext) -> Vec { + pub(crate) async fn build_initial_context( + &self, + turn_context: &TurnContext, + ) -> Vec { let mut items = Vec::::with_capacity(4); let shell = self.user_shell(); items.push( @@ -1398,6 +1558,16 @@ impl Session { if let Some(developer_instructions) = turn_context.developer_instructions.as_deref() { items.push(DeveloperInstructions::new(developer_instructions.to_string()).into()); } + // Add developer instructions from collaboration_mode if they exist and are non-empty + let collaboration_mode = { + let state = self.state.lock().await; + state.session_configuration.collaboration_mode.clone() + }; + if let Some(collab_instructions) = + DeveloperInstructions::from_collaboration_mode(&collaboration_mode) + { + items.push(collab_instructions.into()); + } if let Some(user_instructions) = turn_context.user_instructions.as_deref() { items.push( UserInstructions { @@ -1493,6 +1663,11 @@ impl Session { self.send_token_count_event(turn_context).await; } + pub(crate) async fn set_server_reasoning_included(&self, included: bool) { + let mut state = self.state.lock().await; + state.set_server_reasoning_included(included); + } + async fn send_token_count_event(&self, turn_context: &TurnContext) { let (info, rate_limits) = { let state = self.state.lock().await; @@ -1832,7 +2007,18 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv model, effort, summary, + collaboration_mode, } => { + let collaboration_mode = if let Some(collab_mode) = collaboration_mode { + collab_mode + } else { + let state = sess.state.lock().await; + state.session_configuration.collaboration_mode.with_updates( + model.clone(), + effort, + None, + ) + }; handlers::override_turn_context( &sess, sub.id.clone(), @@ -1840,8 +2026,7 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv cwd, approval_policy, sandbox_policy, - model, - reasoning_effort: effort, + collaboration_mode: Some(collaboration_mode), reasoning_summary: summary, ..Default::default() }, @@ -1858,6 +2043,9 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv Op::PatchApproval { id, decision } => { handlers::patch_approval(&sess, id, decision).await; } + Op::UserInputAnswer { id, response } => { + handlers::request_user_input_response(&sess, id, response).await; + } Op::AddToHistory { text } => { handlers::add_to_history(&sess, &config, text).await; } @@ -1947,8 +2135,11 @@ mod handlers { use codex_protocol::protocol::ThreadRolledBackEvent; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::WarningEvent; + use codex_protocol::request_user_input::RequestUserInputResponse; use crate::context_manager::is_user_turn_boundary; + use codex_protocol::config_types::CollaborationMode; + use codex_protocol::config_types::Settings; use codex_protocol::user_input::UserInput; use codex_rmcp_client::ElicitationAction; use codex_rmcp_client::ElicitationResponse; @@ -1967,6 +2158,18 @@ mod handlers { sub_id: String, updates: SessionSettingsUpdate, ) { + let previous_context = sess + .new_default_turn_with_sub_id(sess.next_internal_sub_id()) + .await; + let previous_collaboration_mode = sess + .state + .lock() + .await + .session_configuration + .collaboration_mode + .clone(); + let next_collaboration_mode = updates.collaboration_mode.clone(); + if let Err(err) = sess.update_settings(updates).await { sess.send_event_raw(Event { id: sub_id, @@ -1976,6 +2179,19 @@ mod handlers { }), }) .await; + return; + } + + let current_context = sess.new_default_turn_with_sub_id(sub_id).await; + let update_items = sess.build_settings_update_items( + Some(&previous_context), + ¤t_context, + &previous_collaboration_mode, + next_collaboration_mode.as_ref(), + ); + if !update_items.is_empty() { + sess.record_conversation_items(¤t_context, &update_items) + .await; } } @@ -1995,18 +2211,27 @@ mod handlers { summary, final_output_json_schema, items, - } => ( - items, - SessionSettingsUpdate { - cwd: Some(cwd), - approval_policy: Some(approval_policy), - sandbox_policy: Some(sandbox_policy), - model: Some(model), - reasoning_effort: Some(effort), - reasoning_summary: Some(summary), - final_output_json_schema: Some(final_output_json_schema), - }, - ), + collaboration_mode, + } => { + let collaboration_mode = collaboration_mode.or_else(|| { + Some(CollaborationMode::Custom(Settings { + model: model.clone(), + reasoning_effort: effort, + developer_instructions: None, + })) + }); + ( + items, + SessionSettingsUpdate { + cwd: Some(cwd), + approval_policy: Some(approval_policy), + sandbox_policy: Some(sandbox_policy), + collaboration_mode, + reasoning_summary: Some(summary), + final_output_json_schema: Some(final_output_json_schema), + }, + ) + } Op::UserInput { items, final_output_json_schema, @@ -2020,6 +2245,14 @@ mod handlers { _ => unreachable!(), }; + let previous_collaboration_mode = sess + .state + .lock() + .await + .session_configuration + .collaboration_mode + .clone(); + let next_collaboration_mode = updates.collaboration_mode.clone(); let Ok(current_context) = sess.new_turn_with_sub_id(sub_id, updates).await else { // new_turn_with_sub_id already emits the error event. return; @@ -2031,17 +2264,12 @@ 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) - { - 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); - } + let update_items = sess.build_settings_update_items( + previous_context.as_ref(), + ¤t_context, + &previous_collaboration_mode, + next_collaboration_mode.as_ref(), + ); if !update_items.is_empty() { sess.record_conversation_items(¤t_context, &update_items) .await; @@ -2136,6 +2364,14 @@ mod handlers { } } + pub async fn request_user_input_response( + sess: &Arc, + id: String, + response: RequestUserInputResponse, + ) { + sess.notify_user_input_response(&id, response).await; + } + pub async fn add_to_history(sess: &Arc, config: &Arc, text: String) { let id = sess.conversation_id; let config = Arc::clone(config); @@ -2241,7 +2477,8 @@ mod handlers { for cwd in cwds { let outcome = skills_manager.skills_for_cwd(&cwd, force_reload).await; let errors = super::errors_to_info(&outcome.errors); - let skills_metadata = super::skills_to_info(&outcome.skills); + let enabled_skills = outcome.enabled_skills(); + let skills_metadata = super::skills_to_info(&enabled_skills); skills.push(SkillsListEntry { cwd, skills: skills_metadata, @@ -2269,6 +2506,7 @@ mod handlers { Arc::clone(&turn_context), vec![UserInput::Text { text: turn_context.compact_prompt().to_string(), + // Compaction prompt is synthesized; no UI element ranges to preserve. text_elements: Vec::new(), }], CompactTask, @@ -2428,7 +2666,6 @@ async fn spawn_review_thread( web_search_mode: Some(review_web_search_mode), }); - let base_instructions = REVIEW_PROMPT.to_string(); let review_prompt = resolved.prompt.clone(); let provider = parent_turn_context.client.get_provider(); let auth_manager = parent_turn_context.client.get_auth_manager(); @@ -2465,7 +2702,6 @@ async fn spawn_review_thread( ghost_snapshot: parent_turn_context.ghost_snapshot.clone(), developer_instructions: None, user_instructions: None, - base_instructions: Some(base_instructions.clone()), compact_prompt: parent_turn_context.compact_prompt.clone(), approval_policy: parent_turn_context.approval_policy, sandbox_policy: parent_turn_context.sandbox_policy.clone(), @@ -2480,6 +2716,7 @@ async fn spawn_review_thread( // Seed the child task with the review prompt as the initial user message. let input: Vec = vec![UserInput::Text { text: review_prompt, + // Review prompt is synthesized; no UI element ranges to preserve. text_elements: Vec::new(), }]; let tc = Arc::new(review_turn_context); @@ -2528,17 +2765,17 @@ fn errors_to_info(errors: &[SkillError]) -> Vec { .collect() } -/// Takes a user message as input and runs a loop where, at each turn, the model +/// Takes a user message as input and runs a loop where, at each sampling request, the model /// replies with either: /// /// - requested function calls /// - an assistant message /// /// While it is possible for the model to return multiple of these items in a -/// single turn, in practice, we generally one item per turn: +/// single sampling request, in practice, we generally one item per sampling request: /// /// - If the model requests a function call, we execute it and send the output -/// back to the model in the next turn. +/// back to the model in the next sampling request. /// - If the model sends only an assistant message, we record it in the /// conversation history and consider the turn complete. /// @@ -2611,13 +2848,13 @@ pub(crate) async fn run_turn( .collect::>(); // Construct the input that we will send to the model. - let turn_input: Vec = { + let sampling_request_input: Vec = { sess.record_conversation_items(&turn_context, &pending_input) .await; sess.clone_history().await.for_prompt() }; - let turn_input_messages = turn_input + let sampling_request_input_messages = sampling_request_input .iter() .filter_map(|item| match parse_turn_item(item) { Some(TurnItem::UserMessage(user_message)) => Some(user_message), @@ -2625,21 +2862,21 @@ pub(crate) async fn run_turn( }) .map(|user_message| user_message.message()) .collect::>(); - match run_model_turn( + match run_sampling_request( Arc::clone(&sess), Arc::clone(&turn_context), Arc::clone(&turn_diff_tracker), &mut client_session, - turn_input, + sampling_request_input, cancellation_token.child_token(), ) .await { - Ok(turn_output) => { - let TurnRunResult { + Ok(sampling_request_output) => { + let SamplingRequestResult { needs_follow_up, - last_agent_message: turn_last_agent_message, - } = turn_output; + last_agent_message: sampling_request_last_agent_message, + } = sampling_request_output; let total_usage_tokens = sess.get_total_token_usage().await; let token_limit_reached = total_usage_tokens >= auto_compact_limit; @@ -2650,13 +2887,13 @@ pub(crate) async fn run_turn( } if !needs_follow_up { - last_agent_message = turn_last_agent_message; + last_agent_message = sampling_request_last_agent_message; sess.notifier() .notify(&UserNotification::AgentTurnComplete { thread_id: sess.conversation_id.to_string(), turn_id: turn_context.sub_id.clone(), cwd: turn_context.cwd.display().to_string(), - input_messages: turn_input_messages, + input_messages: sampling_request_input_messages, last_assistant_message: last_agent_message.clone(), }); break; @@ -2712,14 +2949,14 @@ async fn run_auto_compact(sess: &Arc, turn_context: &Arc) cwd = %turn_context.cwd.display() ) )] -async fn run_model_turn( +async fn run_sampling_request( sess: Arc, turn_context: Arc, turn_diff_tracker: SharedTurnDiffTracker, client_session: &mut ModelClientSession, input: Vec, cancellation_token: CancellationToken, -) -> CodexResult { +) -> CodexResult { let mcp_tools = sess .services .mcp_connection_manager @@ -2743,17 +2980,20 @@ async fn run_model_turn( .get_model_info() .supports_parallel_tool_calls; + let base_instructions = sess.get_base_instructions().await; + let prompt = Prompt { input, tools: router.specs(), parallel_tool_calls: model_supports_parallel, - base_instructions_override: turn_context.base_instructions.clone(), + base_instructions, + personality: None, output_schema: turn_context.final_output_json_schema.clone(), }; let mut retries = 0; loop { - let err = match try_run_turn( + let err = match try_run_sampling_request( Arc::clone(&router), Arc::clone(&sess), Arc::clone(&turn_context), @@ -2793,7 +3033,9 @@ async fn run_model_turn( } _ => backoff(retries), }; - warn!("stream disconnected - retrying turn ({retries}/{max_retries} in {delay:?})...",); + warn!( + "stream disconnected - retrying sampling request ({retries}/{max_retries} in {delay:?})...", + ); // Surface retry information to any UI/front‑end so the // user understands what is happening instead of staring @@ -2813,7 +3055,7 @@ async fn run_model_turn( } #[derive(Debug)] -struct TurnRunResult { +struct SamplingRequestResult { needs_follow_up: bool, last_agent_message: Option, } @@ -2845,7 +3087,7 @@ async fn drain_in_flight( model = %turn_context.client.get_model() ) )] -async fn try_run_turn( +async fn try_run_sampling_request( router: Arc, sess: Arc, turn_context: Arc, @@ -2853,7 +3095,7 @@ async fn try_run_turn( turn_diff_tracker: SharedTurnDiffTracker, prompt: &Prompt, cancellation_token: CancellationToken, -) -> CodexResult { +) -> CodexResult { let rollout_item = RolloutItem::TurnContext(TurnContextItem { cwd: turn_context.cwd.clone(), approval_policy: turn_context.approval_policy, @@ -2861,7 +3103,6 @@ async fn try_run_turn( model: turn_context.client.get_model(), effort: turn_context.client.get_reasoning_effort(), summary: turn_context.client.get_reasoning_summary(), - base_instructions: turn_context.base_instructions.clone(), user_instructions: turn_context.user_instructions.clone(), developer_instructions: turn_context.developer_instructions.clone(), final_output_json_schema: turn_context.final_output_json_schema.clone(), @@ -2897,7 +3138,7 @@ async fn try_run_turn( let mut active_item: Option = None; let mut should_emit_turn_diff = false; let receiving_span = trace_span!("receiving_stream"); - let outcome: CodexResult = loop { + let outcome: CodexResult = loop { let handle_responses = trace_span!( parent: &receiving_span, "handle_responses", @@ -2960,6 +3201,9 @@ async fn try_run_turn( active_item = Some(tracked_item); } } + ResponseEvent::ServerReasoningIncluded(included) => { + sess.set_server_reasoning_included(included).await; + } ResponseEvent::RateLimits(snapshot) => { // Update internal state with latest rate limits, but defer sending until // token usage is available to avoid duplicate TokenCount events. @@ -2983,7 +3227,7 @@ async fn try_run_turn( needs_follow_up |= sess.has_pending_input().await; - break Ok(TurnRunResult { + break Ok(SamplingRequestResult { needs_follow_up, last_agent_message, }); @@ -3103,6 +3347,7 @@ mod tests { use super::*; use crate::CodexAuth; use crate::config::ConfigBuilder; + use crate::config::test_config; use crate::exec::ExecToolCallOutput; use crate::function_tool::FunctionCallError; use crate::shell::default_user_shell; @@ -3146,12 +3391,85 @@ mod tests { use std::sync::Arc; use std::time::Duration as StdDuration; + struct InstructionsTestCase { + slug: &'static str, + expects_apply_patch_instructions: bool, + } + + #[tokio::test] + async fn get_base_instructions_no_user_content() { + let prompt_with_apply_patch_instructions = + include_str!("../prompt_with_apply_patch_instructions.md"); + let test_cases = vec![ + InstructionsTestCase { + slug: "gpt-3.5", + expects_apply_patch_instructions: true, + }, + InstructionsTestCase { + slug: "gpt-4.1", + expects_apply_patch_instructions: true, + }, + InstructionsTestCase { + slug: "gpt-4o", + expects_apply_patch_instructions: true, + }, + InstructionsTestCase { + slug: "gpt-5", + expects_apply_patch_instructions: true, + }, + InstructionsTestCase { + slug: "gpt-5.1", + expects_apply_patch_instructions: false, + }, + InstructionsTestCase { + slug: "codex-mini-latest", + expects_apply_patch_instructions: true, + }, + InstructionsTestCase { + slug: "gpt-oss:120b", + expects_apply_patch_instructions: false, + }, + InstructionsTestCase { + slug: "gpt-5.1-codex", + expects_apply_patch_instructions: false, + }, + InstructionsTestCase { + slug: "gpt-5.1-codex-max", + expects_apply_patch_instructions: false, + }, + ]; + + let (session, _turn_context) = make_session_and_context().await; + + for test_case in test_cases { + let config = test_config(); + let model_info = ModelsManager::construct_model_info_offline(test_case.slug, &config); + if test_case.expects_apply_patch_instructions { + assert_eq!( + model_info.base_instructions.as_str(), + prompt_with_apply_patch_instructions + ); + } + + { + let mut state = session.state.lock().await; + state.session_configuration.base_instructions = + model_info.base_instructions.clone(); + } + + let base_instructions = session.get_base_instructions().await; + assert_eq!(base_instructions.text, model_info.base_instructions); + } + } + #[tokio::test] async fn reconstruct_history_matches_live_compactions() { let (session, turn_context) = make_session_and_context().await; - let (rollout_items, expected) = sample_rollout(&session, &turn_context); + let (rollout_items, expected) = sample_rollout(&session, &turn_context).await; - let reconstructed = session.reconstruct_history_from_rollout(&turn_context, &rollout_items); + let reconstructed = session + .reconstruct_history_from_rollout(&turn_context, &rollout_items) + .await; assert_eq!(expected, reconstructed); } @@ -3159,7 +3477,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, mut expected) = sample_rollout(&session, &turn_context); + let (rollout_items, mut expected) = sample_rollout(&session, &turn_context).await; session .record_initial_history(InitialHistory::Resumed(ResumedHistory { @@ -3169,7 +3487,7 @@ mod tests { })) .await; - expected.extend(session.build_initial_context(&turn_context)); + expected.extend(session.build_initial_context(&turn_context).await); let history = session.state.lock().await.clone_history(); assert_eq!(expected, history.raw_items()); } @@ -3177,7 +3495,7 @@ mod tests { #[tokio::test] async fn record_initial_history_seeds_token_info_from_rollout() { let (session, turn_context) = make_session_and_context().await; - let (mut rollout_items, _expected) = sample_rollout(&session, &turn_context); + let (mut rollout_items, _expected) = sample_rollout(&session, &turn_context).await; let info1 = TokenUsageInfo { total_token_usage: TokenUsage { @@ -3254,13 +3572,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, mut expected) = sample_rollout(&session, &turn_context); + let (rollout_items, mut expected) = sample_rollout(&session, &turn_context).await; session .record_initial_history(InitialHistory::Forked(rollout_items)) .await; - expected.extend(session.build_initial_context(&turn_context)); + expected.extend(session.build_initial_context(&turn_context).await); let history = session.state.lock().await.clone_history(); assert_eq!(expected, history.raw_items()); } @@ -3269,7 +3587,7 @@ mod tests { async fn thread_rollback_drops_last_turn_from_history() { let (sess, tc, rx) = make_session_and_context_with_rx().await; - let initial_context = sess.build_initial_context(tc.as_ref()); + let initial_context = sess.build_initial_context(tc.as_ref()).await; sess.record_into_history(&initial_context, tc.as_ref()) .await; @@ -3326,7 +3644,7 @@ mod tests { async fn thread_rollback_clears_history_when_num_turns_exceeds_existing_turns() { let (sess, tc, rx) = make_session_and_context_with_rx().await; - let initial_context = sess.build_initial_context(tc.as_ref()); + let initial_context = sess.build_initial_context(tc.as_ref()).await; sess.record_into_history(&initial_context, tc.as_ref()) .await; @@ -3352,7 +3670,7 @@ mod tests { async fn thread_rollback_fails_when_turn_in_progress() { let (sess, tc, rx) = make_session_and_context_with_rx().await; - let initial_context = sess.build_initial_context(tc.as_ref()); + let initial_context = sess.build_initial_context(tc.as_ref()).await; sess.record_into_history(&initial_context, tc.as_ref()) .await; @@ -3373,7 +3691,7 @@ mod tests { async fn thread_rollback_fails_when_num_turns_is_zero() { let (sess, tc, rx) = make_session_and_context_with_rx().await; - let initial_context = sess.build_initial_context(tc.as_ref()); + let initial_context = sess.build_initial_context(tc.as_ref()).await; sess.record_into_history(&initial_context, tc.as_ref()) .await; @@ -3396,14 +3714,23 @@ mod tests { let config = build_test_config(codex_home.path()).await; let config = Arc::new(config); let model = ModelsManager::get_model_offline(config.model.as_deref()); + let model_info = ModelsManager::construct_model_info_offline(model.as_str(), &config); + let reasoning_effort = config.model_reasoning_effort; + let collaboration_mode = CollaborationMode::Custom(Settings { + model, + reasoning_effort, + developer_instructions: None, + }); let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), - model, - model_reasoning_effort: config.model_reasoning_effort, + collaboration_mode, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), user_instructions: config.user_instructions.clone(), - base_instructions: config.base_instructions.clone(), + base_instructions: config + .base_instructions + .clone() + .unwrap_or_else(|| model_info.base_instructions.clone()), compact_prompt: config.compact_prompt.clone(), approval_policy: config.approval_policy.clone(), sandbox_policy: config.sandbox_policy.clone(), @@ -3462,14 +3789,23 @@ mod tests { let config = build_test_config(codex_home.path()).await; let config = Arc::new(config); let model = ModelsManager::get_model_offline(config.model.as_deref()); + let model_info = ModelsManager::construct_model_info_offline(model.as_str(), &config); + let reasoning_effort = config.model_reasoning_effort; + let collaboration_mode = CollaborationMode::Custom(Settings { + model, + reasoning_effort, + developer_instructions: None, + }); let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), - model, - model_reasoning_effort: config.model_reasoning_effort, + collaboration_mode, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), user_instructions: config.user_instructions.clone(), - base_instructions: config.base_instructions.clone(), + base_instructions: config + .base_instructions + .clone() + .unwrap_or_else(|| model_info.base_instructions.clone()), compact_prompt: config.compact_prompt.clone(), approval_policy: config.approval_policy.clone(), sandbox_policy: config.sandbox_policy.clone(), @@ -3712,14 +4048,23 @@ mod tests { let exec_policy = ExecPolicyManager::default(); let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit); let model = ModelsManager::get_model_offline(config.model.as_deref()); + let model_info = ModelsManager::construct_model_info_offline(model.as_str(), &config); + let reasoning_effort = config.model_reasoning_effort; + let collaboration_mode = CollaborationMode::Custom(Settings { + model, + reasoning_effort, + developer_instructions: None, + }); let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), - model, - model_reasoning_effort: config.model_reasoning_effort, + collaboration_mode, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), user_instructions: config.user_instructions.clone(), - base_instructions: config.base_instructions.clone(), + base_instructions: config + .base_instructions + .clone() + .unwrap_or_else(|| model_info.base_instructions.clone()), compact_prompt: config.compact_prompt.clone(), approval_policy: config.approval_policy.clone(), sandbox_policy: config.sandbox_policy.clone(), @@ -3729,7 +4074,7 @@ mod tests { }; let per_turn_config = Session::build_per_turn_config(&session_configuration); let model_info = ModelsManager::construct_model_info_offline( - session_configuration.model.as_str(), + session_configuration.collaboration_mode.model(), &per_turn_config, ); let otel_manager = otel_manager( @@ -3807,14 +4152,23 @@ mod tests { let exec_policy = ExecPolicyManager::default(); let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit); let model = ModelsManager::get_model_offline(config.model.as_deref()); + let model_info = ModelsManager::construct_model_info_offline(model.as_str(), &config); + let reasoning_effort = config.model_reasoning_effort; + let collaboration_mode = CollaborationMode::Custom(Settings { + model, + reasoning_effort, + developer_instructions: None, + }); let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), - model, - model_reasoning_effort: config.model_reasoning_effort, + collaboration_mode, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), user_instructions: config.user_instructions.clone(), - base_instructions: config.base_instructions.clone(), + base_instructions: config + .base_instructions + .clone() + .unwrap_or_else(|| model_info.base_instructions.clone()), compact_prompt: config.compact_prompt.clone(), approval_policy: config.approval_policy.clone(), sandbox_policy: config.sandbox_policy.clone(), @@ -3824,7 +4178,7 @@ mod tests { }; let per_turn_config = Session::build_per_turn_config(&session_configuration); let model_info = ModelsManager::construct_model_info_offline( - session_configuration.model.as_str(), + session_configuration.collaboration_mode.model(), &per_turn_config, ); let otel_manager = otel_manager( @@ -3999,6 +4353,8 @@ mod tests { sess.abort_all_tasks(TurnAbortReason::Interrupted).await; + // Interrupts persist a model-visible `` marker into history, but there is no + // separate client-visible event for that marker (only `EventMsg::TurnAborted`). let evt = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) .await .expect("timeout waiting for event") @@ -4007,6 +4363,7 @@ mod tests { EventMsg::TurnAborted(e) => assert_eq!(TurnAbortReason::Interrupted, e.reason), other => panic!("unexpected event: {other:?}"), } + // No extra events should be emitted after an abort. assert!(rx.try_recv().is_err()); } @@ -4029,11 +4386,17 @@ mod tests { sess.abort_all_tasks(TurnAbortReason::Interrupted).await; - let evt = rx.recv().await.expect("event"); + // Even if tasks handle cancellation gracefully, interrupts still result in `TurnAborted` + // being the only client-visible signal. + let evt = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) + .await + .expect("timeout waiting for event") + .expect("event"); match evt.msg { EventMsg::TurnAborted(e) => assert_eq!(TurnAbortReason::Interrupted, e.reason), other => panic!("unexpected event: {other:?}"), } + // No extra events should be emitted after an abort. assert!(rx.try_recv().is_err()); } @@ -4049,42 +4412,67 @@ mod tests { sess.abort_all_tasks(TurnAbortReason::Interrupted).await; - // Drain events until we observe ExitedReviewMode; earlier - // RawResponseItem entries (e.g., environment context) may arrive first. - loop { - let evt = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv()) + // Aborting a review task should exit review mode before surfacing the abort to the client. + // We scan for these events (rather than relying on fixed ordering) since unrelated events + // may interleave. + let mut exited_review_mode_idx = None; + let mut turn_aborted_idx = None; + let mut idx = 0usize; + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(3); + while tokio::time::Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + let evt = tokio::time::timeout(remaining, rx.recv()) .await - .expect("timeout waiting for first event") - .expect("first event"); + .expect("timeout waiting for event") + .expect("event"); + let event_idx = idx; + idx = idx.saturating_add(1); match evt.msg { EventMsg::ExitedReviewMode(ev) => { assert!(ev.review_output.is_none()); + exited_review_mode_idx = Some(event_idx); + } + EventMsg::TurnAborted(ev) => { + assert_eq!(TurnAbortReason::Interrupted, ev.reason); + turn_aborted_idx = Some(event_idx); break; } - // Ignore any non-critical events before exit. - _ => continue, - } - } - loop { - let evt = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) - .await - .expect("timeout waiting for next event") - .expect("event"); - match evt.msg { - EventMsg::RawResponseItem(_) => continue, - EventMsg::ItemStarted(_) | EventMsg::ItemCompleted(_) => continue, - EventMsg::AgentMessage(_) => continue, - EventMsg::TurnAborted(e) => { - assert_eq!(TurnAbortReason::Interrupted, e.reason); - break; - } - other => panic!("unexpected second event: {other:?}"), + _ => {} } } + assert!( + exited_review_mode_idx.is_some(), + "expected ExitedReviewMode after abort" + ); + assert!( + turn_aborted_idx.is_some(), + "expected TurnAborted after abort" + ); + assert!( + exited_review_mode_idx.unwrap() < turn_aborted_idx.unwrap(), + "expected ExitedReviewMode before TurnAborted" + ); - // TODO(jif) investigate what is this? let history = sess.clone_history().await; - let _ = history.raw_items(); + // The `` marker is silent in the event stream, so verify it is still + // recorded in history for the model. + assert!( + history.raw_items().iter().any(|item| { + let ResponseItem::Message { role, content, .. } = item else { + return false; + }; + if role != "user" { + return false; + } + content.iter().any(|content_item| { + let ContentItem::InputText { text } = content_item else { + return false; + }; + text.contains(crate::session_prefix::TURN_ABORTED_OPEN_TAG) + }) + }), + "expected a model-visible turn aborted marker in history after interrupt" + ); } #[tokio::test] @@ -4139,14 +4527,14 @@ mod tests { } } - fn sample_rollout( + async fn sample_rollout( session: &Session, turn_context: &TurnContext, ) -> (Vec, Vec) { let mut rollout_items = Vec::new(); let mut live_history = ContextManager::new(); - let initial_context = session.build_initial_context(turn_context); + let initial_context = session.build_initial_context(turn_context).await; for item in &initial_context { rollout_items.push(RolloutItem::ResponseItem(item.clone())); } @@ -4176,7 +4564,7 @@ mod tests { let snapshot1 = live_history.clone().for_prompt(); let user_messages1 = collect_user_messages(&snapshot1); let rebuilt1 = compact::build_compacted_history( - session.build_initial_context(turn_context), + session.build_initial_context(turn_context).await, &user_messages1, summary1, ); @@ -4210,7 +4598,7 @@ mod tests { let snapshot2 = live_history.clone().for_prompt(); let user_messages2 = collect_user_messages(&snapshot2); let rebuilt2 = compact::build_compacted_history( - session.build_initial_context(turn_context), + session.build_initial_context(turn_context).await, &user_messages2, summary2, ); @@ -4228,7 +4616,7 @@ mod tests { }], }; live_history.record_items(std::iter::once(&user3), turn_context.truncation_policy); - rollout_items.push(RolloutItem::ResponseItem(user3.clone())); + rollout_items.push(RolloutItem::ResponseItem(user3)); let assistant3 = ResponseItem::Message { id: None, @@ -4238,7 +4626,7 @@ mod tests { }], }; live_history.record_items(std::iter::once(&assistant3), turn_context.truncation_policy); - rollout_items.push(RolloutItem::ResponseItem(assistant3.clone())); + rollout_items.push(RolloutItem::ResponseItem(assistant3)); (rollout_items, live_history.for_prompt()) } diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 49409f8e84..b855612113 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::sync::Arc; use std::sync::atomic::AtomicU64; @@ -9,9 +10,12 @@ use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ExecApprovalRequestEvent; use codex_protocol::protocol::Op; +use codex_protocol::protocol::RequestUserInputEvent; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::Submission; +use codex_protocol::request_user_input::RequestUserInputArgs; +use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; use std::time::Duration; use tokio::time::timeout; @@ -229,6 +233,20 @@ async fn forward_events( ) .await; } + Event { + id, + msg: EventMsg::RequestUserInput(event), + } => { + handle_request_user_input( + &codex, + id, + &parent_session, + &parent_ctx, + event, + &cancel_token, + ) + .await; + } other => { match tx_sub.send(other).or_cancel(&cancel_token).await { Ok(Ok(())) => {} @@ -334,6 +352,55 @@ async fn handle_patch_approval( let _ = codex.submit(Op::PatchApproval { id, decision }).await; } +async fn handle_request_user_input( + codex: &Codex, + id: String, + parent_session: &Session, + parent_ctx: &TurnContext, + event: RequestUserInputEvent, + cancel_token: &CancellationToken, +) { + let args = RequestUserInputArgs { + questions: event.questions, + }; + let response_fut = + parent_session.request_user_input(parent_ctx, parent_ctx.sub_id.clone(), args); + let response = await_user_input_with_cancel( + response_fut, + parent_session, + &parent_ctx.sub_id, + cancel_token, + ) + .await; + let _ = codex.submit(Op::UserInputAnswer { id, response }).await; +} + +async fn await_user_input_with_cancel( + fut: F, + parent_session: &Session, + sub_id: &str, + cancel_token: &CancellationToken, +) -> RequestUserInputResponse +where + F: core::future::Future>, +{ + tokio::select! { + biased; + _ = cancel_token.cancelled() => { + let empty = RequestUserInputResponse { + answers: HashMap::new(), + }; + parent_session + .notify_user_input_response(sub_id, empty.clone()) + .await; + empty + } + response = fut => response.unwrap_or_else(|| RequestUserInputResponse { + answers: HashMap::new(), + }), + } +} + /// Await an approval decision, aborting on cancellation. async fn await_approval_with_cancel( fut: F, diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 4dc56f10d2..970eb0054d 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -15,6 +15,7 @@ use crate::protocol::EventMsg; use crate::protocol::TurnContextItem; use crate::protocol::TurnStartedEvent; use crate::protocol::WarningEvent; +use crate::session_prefix::TURN_ABORTED_OPEN_TAG; use crate::truncate::TruncationPolicy; use crate::truncate::approx_token_count; use crate::truncate::truncate_text; @@ -46,7 +47,7 @@ pub(crate) async fn run_inline_auto_compact_task( let prompt = turn_context.compact_prompt().to_string(); let input = vec![UserInput::Text { text: prompt, - // Plain text conversion has no UI element ranges. + // Compaction prompt is synthesized; no UI element ranges to preserve. text_elements: Vec::new(), }]; @@ -90,7 +91,6 @@ async fn run_compact_task_inner( model: turn_context.client.get_model(), effort: turn_context.client.get_reasoning_effort(), summary: turn_context.client.get_reasoning_summary(), - base_instructions: turn_context.base_instructions.clone(), user_instructions: turn_context.user_instructions.clone(), developer_instructions: turn_context.developer_instructions.clone(), final_output_json_schema: turn_context.final_output_json_schema.clone(), @@ -104,6 +104,7 @@ async fn run_compact_task_inner( let turn_input_len = turn_input.len(); let prompt = Prompt { input: turn_input, + base_instructions: sess.get_base_instructions().await, ..Default::default() }; let attempt_result = drain_to_completed(&sess, turn_context.as_ref(), &prompt).await; @@ -167,7 +168,7 @@ async fn run_compact_task_inner( let summary_text = format!("{SUMMARY_PREFIX}\n{summary_suffix}"); let user_messages = collect_user_messages(history_items); - let initial_context = sess.build_initial_context(turn_context.as_ref()); + let initial_context = sess.build_initial_context(turn_context.as_ref()).await; let mut new_history = build_compacted_history(initial_context, &user_messages, &summary_text); let ghost_snapshots: Vec = history_items .iter() @@ -223,11 +224,31 @@ pub(crate) fn collect_user_messages(items: &[ResponseItem]) -> Vec { Some(user.message()) } } - _ => None, + _ => collect_turn_aborted_marker(item), }) .collect() } +fn collect_turn_aborted_marker(item: &ResponseItem) -> Option { + let ResponseItem::Message { role, content, .. } = item else { + return None; + }; + if role != "user" { + return None; + } + + let text = content_items_to_text(content)?; + if text + .trim_start() + .to_ascii_lowercase() + .starts_with(TURN_ABORTED_OPEN_TAG) + { + Some(text) + } else { + None + } +} + pub(crate) fn is_summary_message(message: &str) -> bool { message.starts_with(format!("{SUMMARY_PREFIX}\n").as_str()) } @@ -316,6 +337,9 @@ async fn drain_to_completed( sess.record_into_history(std::slice::from_ref(&item), turn_context) .await; } + Ok(ResponseEvent::ServerReasoningIncluded(included)) => { + sess.set_server_reasoning_included(included).await; + } Ok(ResponseEvent::RateLimits(snapshot)) => { sess.update_rate_limits(turn_context, snapshot).await; } @@ -334,6 +358,7 @@ async fn drain_to_completed( mod tests { use super::*; + use crate::session_prefix::TURN_ABORTED_OPEN_TAG; use pretty_assertions::assert_eq; #[test] @@ -486,4 +511,41 @@ mod tests { }; assert_eq!(summary, summary_text); } + + #[test] + fn build_compacted_history_preserves_turn_aborted_markers() { + let marker = format!( + "{TURN_ABORTED_OPEN_TAG}\n turn-1\n interrupted\n" + ); + let items = vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: marker.clone(), + }], + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "real user message".to_string(), + }], + }, + ]; + + let user_messages = collect_user_messages(&items); + let history = build_compacted_history(Vec::new(), &user_messages, "SUMMARY"); + + let found_marker = history.iter().any(|item| match item { + ResponseItem::Message { role, content, .. } if role == "user" => { + content_items_to_text(content).is_some_and(|text| text == marker) + } + _ => false, + }); + assert!( + found_marker, + "expected compacted history to retain marker" + ); + } } diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index dd038c5287..ff5ca53010 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -54,7 +54,8 @@ async fn run_remote_compact_task_inner_impl( input: history.for_prompt(), tools: vec![], parallel_tool_calls: false, - base_instructions_override: turn_context.base_instructions.clone(), + base_instructions: sess.get_base_instructions().await, + personality: None, output_schema: None, }; diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index fb6829bb96..9c12272d94 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -1,14 +1,16 @@ use crate::config::CONFIG_TOML_FILE; use crate::config::types::McpServerConfig; use crate::config::types::Notice; +use crate::path_utils::resolve_symlink_write_paths; +use crate::path_utils::write_atomically; use anyhow::Context; use codex_protocol::config_types::TrustLevel; use codex_protocol::openai_models::ReasoningEffort; use std::collections::BTreeMap; use std::path::Path; use std::path::PathBuf; -use tempfile::NamedTempFile; use tokio::task; +use toml_edit::ArrayOfTables; use toml_edit::DocumentMut; use toml_edit::Item as TomlItem; use toml_edit::Table as TomlTable; @@ -36,6 +38,8 @@ pub enum ConfigEdit { RecordModelMigrationSeen { from: String, to: String }, /// Replace the entire `[mcp_servers]` table. ReplaceMcpServers(BTreeMap), + /// Set or clear a skill config entry under `[[skills.config]]`. + SetSkillConfig { path: PathBuf, enabled: bool }, /// Set trust_level under `[projects.""]`, /// migrating inline tables to explicit tables. SetProjectTrustLevel { path: PathBuf, level: TrustLevel }, @@ -298,6 +302,9 @@ impl ConfigDocument { value(*acknowledged), )), ConfigEdit::ReplaceMcpServers(servers) => Ok(self.replace_mcp_servers(servers)), + ConfigEdit::SetSkillConfig { path, enabled } => { + Ok(self.set_skill_config(path.as_path(), *enabled)) + } ConfigEdit::SetPath { segments, value } => Ok(self.insert(segments, value.clone())), ConfigEdit::ClearPath { segments } => Ok(self.clear_owned(segments)), ConfigEdit::SetProjectTrustLevel { path, level } => { @@ -387,6 +394,113 @@ impl ConfigDocument { true } + fn set_skill_config(&mut self, path: &Path, enabled: bool) -> bool { + let normalized_path = normalize_skill_config_path(path); + let mut remove_skills_table = false; + let mut mutated = false; + + { + let root = self.doc.as_table_mut(); + let skills_item = match root.get_mut("skills") { + Some(item) => item, + None => { + if enabled { + return false; + } + root.insert( + "skills", + TomlItem::Table(document_helpers::new_implicit_table()), + ); + let Some(item) = root.get_mut("skills") else { + return false; + }; + item + } + }; + + if document_helpers::ensure_table_for_write(skills_item).is_none() { + if enabled { + return false; + } + *skills_item = TomlItem::Table(document_helpers::new_implicit_table()); + } + let Some(skills_table) = skills_item.as_table_mut() else { + return false; + }; + + let config_item = match skills_table.get_mut("config") { + Some(item) => item, + None => { + if enabled { + return false; + } + skills_table.insert("config", TomlItem::ArrayOfTables(ArrayOfTables::new())); + let Some(item) = skills_table.get_mut("config") else { + return false; + }; + item + } + }; + + if !matches!(config_item, TomlItem::ArrayOfTables(_)) { + if enabled { + return false; + } + *config_item = TomlItem::ArrayOfTables(ArrayOfTables::new()); + } + + let TomlItem::ArrayOfTables(overrides) = config_item else { + return false; + }; + + let existing_index = overrides.iter().enumerate().find_map(|(idx, table)| { + table + .get("path") + .and_then(|item| item.as_str()) + .map(Path::new) + .map(normalize_skill_config_path) + .filter(|value| *value == normalized_path) + .map(|_| idx) + }); + + if enabled { + if let Some(index) = existing_index { + overrides.remove(index); + mutated = true; + if overrides.is_empty() { + skills_table.remove("config"); + if skills_table.is_empty() { + remove_skills_table = true; + } + } + } + } else if let Some(index) = existing_index { + for (idx, table) in overrides.iter_mut().enumerate() { + if idx == index { + table["path"] = value(normalized_path); + table["enabled"] = value(false); + mutated = true; + break; + } + } + } else { + let mut entry = TomlTable::new(); + entry.set_implicit(false); + entry["path"] = value(normalized_path); + entry["enabled"] = value(false); + overrides.push(entry); + mutated = true; + } + } + + if remove_skills_table { + let root = self.doc.as_table_mut(); + root.remove("skills"); + } + + mutated + } + fn scoped_segments(&self, scope: Scope, segments: &[&str]) -> Vec { let resolved: Vec = segments .iter() @@ -494,6 +608,13 @@ impl ConfigDocument { } } +fn normalize_skill_config_path(path: &Path) -> String { + dunce::canonicalize(path) + .unwrap_or_else(|_| path.to_path_buf()) + .to_string_lossy() + .to_string() +} + /// Persist edits using a blocking strategy. pub fn apply_blocking( codex_home: &Path, @@ -505,10 +626,14 @@ pub fn apply_blocking( } let config_path = codex_home.join(CONFIG_TOML_FILE); - let serialized = match std::fs::read_to_string(&config_path) { - Ok(contents) => contents, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(), - Err(err) => return Err(err.into()), + let write_paths = resolve_symlink_write_paths(&config_path)?; + let serialized = match write_paths.read_path { + Some(path) => match std::fs::read_to_string(&path) { + Ok(contents) => contents, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(err) => return Err(err.into()), + }, + None => String::new(), }; let doc = if serialized.is_empty() { @@ -534,22 +659,13 @@ pub fn apply_blocking( return Ok(()); } - std::fs::create_dir_all(codex_home).with_context(|| { + write_atomically(&write_paths.write_path, &document.doc.to_string()).with_context(|| { format!( - "failed to create Codex home directory at {}", - codex_home.display() + "failed to persist config.toml at {}", + write_paths.write_path.display() ) })?; - let tmp = NamedTempFile::new_in(codex_home)?; - std::fs::write(tmp.path(), document.doc.to_string()).with_context(|| { - format!( - "failed to write temporary config file at {}", - tmp.path().display() - ) - })?; - tmp.persist(config_path)?; - Ok(()) } @@ -693,6 +809,8 @@ mod tests { use crate::config::types::McpServerTransportConfig; use codex_protocol::openai_models::ReasoningEffort; use pretty_assertions::assert_eq; + #[cfg(unix)] + use std::os::unix::fs::symlink; use tempfile::tempdir; use toml::Value as TomlValue; @@ -737,6 +855,54 @@ model_reasoning_effort = "high" assert_eq!(contents, "enabled = true\n"); } + #[test] + fn set_skill_config_writes_disabled_entry() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + + ConfigEditsBuilder::new(codex_home) + .with_edits([ConfigEdit::SetSkillConfig { + path: PathBuf::from("/tmp/skills/demo/SKILL.md"), + enabled: false, + }]) + .apply_blocking() + .expect("persist"); + + let contents = + std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let expected = r#"[[skills.config]] +path = "/tmp/skills/demo/SKILL.md" +enabled = false +"#; + assert_eq!(contents, expected); + } + + #[test] + fn set_skill_config_removes_entry_when_enabled() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + std::fs::write( + codex_home.join(CONFIG_TOML_FILE), + r#"[[skills.config]] +path = "/tmp/skills/demo/SKILL.md" +enabled = false +"#, + ) + .expect("seed config"); + + ConfigEditsBuilder::new(codex_home) + .with_edits([ConfigEdit::SetSkillConfig { + path: PathBuf::from("/tmp/skills/demo/SKILL.md"), + enabled: true, + }]) + .apply_blocking() + .expect("persist"); + + let contents = + std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + assert_eq!(contents, ""); + } + #[test] fn blocking_set_model_preserves_inline_table_contents() { let tmp = tempdir().expect("tmpdir"); @@ -784,6 +950,71 @@ profiles = { fast = { model = "gpt-4o", sandbox_mode = "strict" } } ); } + #[cfg(unix)] + #[test] + fn blocking_set_model_writes_through_symlink_chain() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + let target_dir = tempdir().expect("target dir"); + let target_path = target_dir.path().join(CONFIG_TOML_FILE); + let link_path = codex_home.join("config-link.toml"); + let config_path = codex_home.join(CONFIG_TOML_FILE); + + symlink(&target_path, &link_path).expect("symlink link"); + symlink("config-link.toml", &config_path).expect("symlink config"); + + apply_blocking( + codex_home, + None, + &[ConfigEdit::SetModel { + model: Some("gpt-5.1-codex".to_string()), + effort: Some(ReasoningEffort::High), + }], + ) + .expect("persist"); + + let meta = std::fs::symlink_metadata(&config_path).expect("config metadata"); + assert!(meta.file_type().is_symlink()); + + let contents = std::fs::read_to_string(&target_path).expect("read target"); + let expected = r#"model = "gpt-5.1-codex" +model_reasoning_effort = "high" +"#; + assert_eq!(contents, expected); + } + + #[cfg(unix)] + #[test] + fn blocking_set_model_replaces_symlink_on_cycle() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + let link_a = codex_home.join("a.toml"); + let link_b = codex_home.join("b.toml"); + let config_path = codex_home.join(CONFIG_TOML_FILE); + + symlink("b.toml", &link_a).expect("symlink a"); + symlink("a.toml", &link_b).expect("symlink b"); + symlink("a.toml", &config_path).expect("symlink config"); + + apply_blocking( + codex_home, + None, + &[ConfigEdit::SetModel { + model: Some("gpt-5.1-codex".to_string()), + effort: None, + }], + ) + .expect("persist"); + + let meta = std::fs::symlink_metadata(&config_path).expect("config metadata"); + assert!(!meta.file_type().is_symlink()); + + let contents = std::fs::read_to_string(&config_path).expect("read config"); + let expected = r#"model = "gpt-5.1-codex" +"#; + assert_eq!(contents, expected); + } + #[test] fn batch_write_table_upsert_preserves_inline_comments() { let tmp = tempdir().expect("tmpdir"); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 6228bc3ac3..d3add11096 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1,4 +1,6 @@ use crate::auth::AuthCredentialsStoreMode; +use crate::config::edit::ConfigEdit; +use crate::config::edit::ConfigEditsBuilder; use crate::config::types::DEFAULT_OTEL_ENVIRONMENT; use crate::config::types::History; use crate::config::types::McpServerConfig; @@ -9,10 +11,12 @@ use crate::config::types::Notifications; use crate::config::types::OtelConfig; use crate::config::types::OtelConfigToml; use crate::config::types::OtelExporterKind; +use crate::config::types::Personality; use crate::config::types::SandboxWorkspaceWrite; use crate::config::types::ScrollInputMode; use crate::config::types::ShellEnvironmentPolicy; use crate::config::types::ShellEnvironmentPolicyToml; +use crate::config::types::SkillsConfig; use crate::config::types::Tui; use crate::config::types::UriBasedFileOpener; use crate::config_loader::ConfigLayerStack; @@ -124,6 +128,9 @@ pub struct Config { /// Info needed to make an API request to the model. pub model_provider: ModelProviderInfo, + /// Optionally specify the personality of the model + pub model_personality: Option, + /// Approval policy for executing commands. pub approval_policy: Constrained, @@ -392,6 +399,7 @@ pub struct ConfigBuilder { cli_overrides: Option>, harness_overrides: Option, loader_overrides: Option, + fallback_cwd: Option, } impl ConfigBuilder { @@ -415,21 +423,29 @@ impl ConfigBuilder { self } + pub fn fallback_cwd(mut self, fallback_cwd: Option) -> Self { + self.fallback_cwd = fallback_cwd; + self + } + pub async fn build(self) -> std::io::Result { let Self { codex_home, cli_overrides, harness_overrides, loader_overrides, + fallback_cwd, } = self; let codex_home = codex_home.map_or_else(find_codex_home, std::io::Result::Ok)?; let cli_overrides = cli_overrides.unwrap_or_default(); - let harness_overrides = harness_overrides.unwrap_or_default(); + let mut harness_overrides = harness_overrides.unwrap_or_default(); let loader_overrides = loader_overrides.unwrap_or_default(); - let cwd = match harness_overrides.cwd.as_deref() { + let cwd_override = harness_overrides.cwd.as_deref().or(fallback_cwd.as_deref()); + let cwd = match cwd_override { Some(path) => AbsolutePathBuf::try_from(path)?, None => AbsolutePathBuf::current_dir()?, }; + harness_overrides.cwd = Some(cwd.to_path_buf()); let config_layer_stack = load_config_layers_state(&codex_home, Some(cwd), &cli_overrides, loader_overrides) .await?; @@ -528,7 +544,7 @@ pub async fn load_config_as_toml_with_cli_overrides( Ok(cfg) } -fn deserialize_config_toml_with_base( +pub(crate) fn deserialize_config_toml_with_base( root_value: TomlValue, config_base_dir: &Path, ) -> std::io::Result { @@ -750,30 +766,17 @@ pub fn set_default_oss_provider(codex_home: &Path, provider: &str) -> std::io::R )); } } - let config_path = codex_home.join(CONFIG_TOML_FILE); - - // Read existing config or create empty string if file doesn't exist - let content = match std::fs::read_to_string(&config_path) { - Ok(content) => content, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(), - Err(e) => return Err(e), - }; - - // Parse as DocumentMut for editing while preserving structure - let mut doc = content.parse::().map_err(|e| { - std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!("failed to parse config.toml: {e}"), - ) - })?; - - // Set the default_oss_provider at root level use toml_edit::value; - doc["oss_provider"] = value(provider); - // Write the modified document back - std::fs::write(&config_path, doc.to_string())?; - Ok(()) + let edits = [ConfigEdit::SetPath { + segments: vec!["oss_provider".to_string()], + value: value(provider), + }]; + + ConfigEditsBuilder::new(codex_home) + .with_edits(edits) + .apply_blocking() + .map_err(|err| std::io::Error::other(format!("failed to persist config.toml: {err}"))) } /// Base config deserialized from ~/.codex/config.toml. @@ -817,6 +820,12 @@ pub struct ConfigToml { #[serde(default)] pub developer_instructions: Option, + /// Optional path to a file containing model instructions that will override + /// the built-in instructions for the selected model. Users are STRONGLY + /// DISCOURAGED from using this field, as deviating from the instructions + /// sanctioned by Codex will likely degrade model performance. + pub model_instructions: Option, + /// Compact prompt used for history compaction. pub compact_prompt: Option, @@ -900,6 +909,10 @@ pub struct ConfigToml { /// Override to force-enable reasoning summaries for the configured model. pub model_supports_reasoning_summaries: Option, + /// EXPERIMENTAL + /// Optionally specify a personality for the model + pub model_personality: Option, + /// Base URL for requests to ChatGPT (as opposed to the OpenAI API). pub chatgpt_base_url: Option, @@ -911,6 +924,9 @@ pub struct ConfigToml { /// Nested tools section for feature toggles pub tools: Option, + /// User-level skill config entries keyed by SKILL.md path. + pub skills: Option, + /// Centralized feature flags (new). Prefer this over individual toggles. #[serde(default)] // Injects known feature keys into the schema and forbids unknown keys. @@ -955,6 +971,8 @@ pub struct ConfigToml { pub notice: Option, /// Legacy, now use features + /// Deprecated: ignored. Use `model_instructions`. + #[schemars(skip)] pub experimental_instructions_file: Option, pub experimental_compact_prompt_file: Option, pub experimental_use_unified_exec_tool: Option, @@ -1422,14 +1440,12 @@ impl Config { // Load base instructions override from a file if specified. If the // path is relative, resolve it against the effective cwd so the // behaviour matches other path-like config values. - let experimental_instructions_path = config_profile - .experimental_instructions_file + let model_instructions_path = config_profile + .model_instructions .as_ref() - .or(cfg.experimental_instructions_file.as_ref()); - let file_base_instructions = Self::try_read_non_empty_file( - experimental_instructions_path, - "experimental instructions file", - )?; + .or(cfg.model_instructions.as_ref()); + let file_base_instructions = + Self::try_read_non_empty_file(model_instructions_path, "model instructions file")?; let base_instructions = base_instructions.or(file_base_instructions); let developer_instructions = developer_instructions.or(cfg.developer_instructions); @@ -1481,6 +1497,7 @@ impl Config { notify: cfg.notify, user_instructions, base_instructions, + model_personality: config_profile.model_personality.or(cfg.model_personality), developer_instructions, compact_prompt, // The config.toml omits "_mode" because it's a config file. However, "_mode" @@ -1671,6 +1688,30 @@ impl Config { } } +pub(crate) fn uses_deprecated_instructions_file(config_layer_stack: &ConfigLayerStack) -> bool { + config_layer_stack + .layers_high_to_low() + .into_iter() + .any(|layer| toml_uses_deprecated_instructions_file(&layer.config)) +} + +fn toml_uses_deprecated_instructions_file(value: &TomlValue) -> bool { + let Some(table) = value.as_table() else { + return false; + }; + if table.contains_key("experimental_instructions_file") { + return true; + } + let Some(profiles) = table.get("profiles").and_then(TomlValue::as_table) else { + return false; + }; + profiles.values().any(|profile| { + profile.as_table().is_some_and(|profile_table| { + profile_table.contains_key("experimental_instructions_file") + }) + }) +} + /// Returns the path to the Codex configuration directory, which can be /// specified by the `CODEX_HOME` environment variable. If not set, defaults to /// `~/.codex`. @@ -2299,6 +2340,52 @@ trust_level = "trusted" Ok(()) } + #[tokio::test] + async fn project_profile_overrides_user_profile() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let workspace = TempDir::new()?; + let workspace_key = workspace.path().to_string_lossy().replace('\\', "\\\\"); + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + format!( + r#" +profile = "global" + +[profiles.global] +model = "gpt-global" + +[profiles.project] +model = "gpt-project" + +[projects."{workspace_key}"] +trust_level = "trusted" +"#, + ), + )?; + let project_config_dir = workspace.path().join(".codex"); + std::fs::create_dir_all(&project_config_dir)?; + std::fs::write( + project_config_dir.join(CONFIG_TOML_FILE), + r#" +profile = "project" +"#, + )?; + + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .harness_overrides(ConfigOverrides { + cwd: Some(workspace.path().to_path_buf()), + ..Default::default() + }) + .build() + .await?; + + assert_eq!(config.active_profile.as_deref(), Some("project")); + assert_eq!(config.model.as_deref(), Some("gpt-project")); + + Ok(()) + } + #[test] fn profile_sandbox_mode_overrides_base() -> std::io::Result<()> { let codex_home = TempDir::new()?; @@ -3612,6 +3699,7 @@ model_verbosity = "high" model_reasoning_summary: ReasoningSummary::Detailed, model_supports_reasoning_summaries: None, model_verbosity: None, + model_personality: None, chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), base_instructions: None, developer_instructions: None, @@ -3699,6 +3787,7 @@ model_verbosity = "high" model_reasoning_summary: ReasoningSummary::default(), model_supports_reasoning_summaries: None, model_verbosity: None, + model_personality: None, chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), base_instructions: None, developer_instructions: None, @@ -3801,6 +3890,7 @@ model_verbosity = "high" model_reasoning_summary: ReasoningSummary::default(), model_supports_reasoning_summaries: None, model_verbosity: None, + model_personality: None, chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), base_instructions: None, developer_instructions: None, @@ -3889,6 +3979,7 @@ model_verbosity = "high" model_reasoning_summary: ReasoningSummary::Detailed, model_supports_reasoning_summaries: None, model_verbosity: Some(Verbosity::High), + model_personality: None, chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), base_instructions: None, developer_instructions: None, diff --git a/codex-rs/core/src/config/profile.rs b/codex-rs/core/src/config/profile.rs index d8630f15d1..80ae772d0b 100644 --- a/codex-rs/core/src/config/profile.rs +++ b/codex-rs/core/src/config/profile.rs @@ -3,6 +3,7 @@ use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; +use crate::config::types::Personality; use crate::protocol::AskForApproval; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode; @@ -24,7 +25,12 @@ pub struct ConfigProfile { pub model_reasoning_effort: Option, pub model_reasoning_summary: Option, pub model_verbosity: Option, + pub model_personality: Option, pub chatgpt_base_url: Option, + /// Optional path to a file containing model instructions. + pub model_instructions: Option, + /// Deprecated: ignored. Use `model_instructions`. + #[schemars(skip)] pub experimental_instructions_file: Option, pub experimental_compact_prompt_file: Option, pub include_apply_patch_tool: Option, diff --git a/codex-rs/core/src/config/service.rs b/codex-rs/core/src/config/service.rs index 913c02df1d..b6ed9f617e 100644 --- a/codex-rs/core/src/config/service.rs +++ b/codex-rs/core/src/config/service.rs @@ -9,6 +9,9 @@ use crate::config_loader::LoaderOverrides; use crate::config_loader::load_config_layers_state; use crate::config_loader::merge_toml_values; use crate::path_utils; +use crate::path_utils::SymlinkWritePaths; +use crate::path_utils::resolve_symlink_write_paths; +use crate::path_utils::write_atomically; use codex_app_server_protocol::Config as ApiConfig; use codex_app_server_protocol::ConfigBatchWriteParams; use codex_app_server_protocol::ConfigLayerMetadata; @@ -27,6 +30,7 @@ use std::borrow::Cow; use std::path::Path; use std::path::PathBuf; use thiserror::Error; +use tokio::task; use toml::Value as TomlValue; use toml_edit::Item as TomlItem; @@ -362,19 +366,30 @@ impl ConfigService { async fn create_empty_user_layer( config_toml: &AbsolutePathBuf, ) -> Result { - let toml_value = match tokio::fs::read_to_string(config_toml).await { - Ok(contents) => toml::from_str(&contents).map_err(|e| { - ConfigServiceError::toml("failed to parse existing user config.toml", e) - })?, - Err(e) => { - if e.kind() == std::io::ErrorKind::NotFound { - tokio::fs::write(config_toml, "").await.map_err(|e| { - ConfigServiceError::io("failed to create empty user config.toml", e) - })?; + let SymlinkWritePaths { + read_path, + write_path, + } = resolve_symlink_write_paths(config_toml.as_path()) + .map_err(|err| ConfigServiceError::io("failed to resolve user config path", err))?; + let toml_value = match read_path { + Some(path) => match tokio::fs::read_to_string(&path).await { + Ok(contents) => toml::from_str(&contents).map_err(|e| { + ConfigServiceError::toml("failed to parse existing user config.toml", e) + })?, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + write_empty_user_config(write_path.clone()).await?; TomlValue::Table(toml::map::Map::new()) - } else { - return Err(ConfigServiceError::io("failed to read user config.toml", e)); } + Err(err) => { + return Err(ConfigServiceError::io( + "failed to read user config.toml", + err, + )); + } + }, + None => { + write_empty_user_config(write_path).await?; + TomlValue::Table(toml::map::Map::new()) } }; Ok(ConfigLayerEntry::new( @@ -385,6 +400,13 @@ async fn create_empty_user_layer( )) } +async fn write_empty_user_config(write_path: PathBuf) -> Result<(), ConfigServiceError> { + task::spawn_blocking(move || write_atomically(&write_path, "")) + .await + .map_err(|err| ConfigServiceError::anyhow("config persistence task panicked", err.into()))? + .map_err(|err| ConfigServiceError::io("failed to create empty user config.toml", err)) +} + fn parse_value(value: JsonValue) -> Result, String> { if value.is_null() { return Ok(None); diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index 981f9b541a..7841367246 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -423,10 +423,11 @@ 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, JsonSchema)] +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, Default)] #[serde(rename_all = "snake_case")] pub enum ScrollInputMode { /// Infer wheel vs trackpad behavior per scroll stream. + #[default] Auto, /// Always treat scroll events as mouse-wheel input (fixed lines per tick). Wheel, @@ -434,12 +435,6 @@ pub enum ScrollInputMode { Trackpad, } -impl Default for ScrollInputMode { - fn default() -> Self { - Self::Auto - } -} - /// Collection of settings that are specific to the TUI. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] #[schemars(deny_unknown_fields)] @@ -605,6 +600,20 @@ impl Notice { pub(crate) const TABLE_KEY: &'static str = "notice"; } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct SkillConfig { + pub path: AbsolutePathBuf, + pub enabled: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct SkillsConfig { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub config: Vec, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct SandboxWorkspaceWrite { @@ -739,6 +748,14 @@ impl Default for ShellEnvironmentPolicy { } } +#[derive(Default, Debug, Clone, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum Personality { + Friendly, + #[default] + Pragmatic, +} + #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index 9a8791eeef..5afdcc9b18 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -12,10 +12,13 @@ mod tests; use crate::config::CONFIG_TOML_FILE; use crate::config::ConfigToml; +use crate::config::deserialize_config_toml_with_base; use crate::config_loader::config_requirements::ConfigRequirementsWithSources; use crate::config_loader::layer_io::LoadedConfigLayers; +use crate::git_info::resolve_root_git_project_for_trust; use codex_app_server_protocol::ConfigLayerSource; use codex_protocol::config_types::SandboxMode; +use codex_protocol::config_types::TrustLevel; use codex_protocol::protocol::AskForApproval; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::AbsolutePathBufGuard; @@ -64,9 +67,9 @@ const DEFAULT_PROJECT_ROOT_MARKERS: &[&str] = &[".git"]; /// - admin: managed preferences (*) /// - system `/etc/codex/config.toml` /// - user `${CODEX_HOME}/config.toml` -/// - cwd `${PWD}/config.toml` -/// - tree parent directories up to root looking for `./.codex/config.toml` -/// - repo `$(git rev-parse --show-toplevel)/.codex/config.toml` +/// - cwd `${PWD}/config.toml` (only when the directory is trusted) +/// - tree parent directories up to root looking for `./.codex/config.toml` (trusted only) +/// - repo `$(git rev-parse --show-toplevel)/.codex/config.toml` (trusted only) /// - runtime e.g., --config flags, model selector in UI /// /// (*) Only available on macOS via managed device profiles. @@ -114,6 +117,12 @@ pub async fn load_config_layers_state( let mut layers = Vec::::new(); + let cli_overrides_layer = if cli_overrides.is_empty() { + None + } else { + Some(overrides::build_cli_overrides_layer(cli_overrides)) + }; + // Include an entry for the "system" config folder, loading its config.toml, // if it exists. let system_config_toml_file = if cfg!(unix) { @@ -158,17 +167,22 @@ pub async fn load_config_layers_state( for layer in &layers { merge_toml_values(&mut merged_so_far, &layer.config); } + if let Some(cli_overrides_layer) = cli_overrides_layer.as_ref() { + merge_toml_values(&mut merged_so_far, cli_overrides_layer); + } + let project_root_markers = project_root_markers_from_config(&merged_so_far)? .unwrap_or_else(default_project_root_markers); - - let project_root = find_project_root(&cwd, &project_root_markers).await?; - let project_layers = load_project_layers(&cwd, &project_root).await?; - layers.extend(project_layers); + if let Some(project_root) = + trusted_project_root(&merged_so_far, &cwd, &project_root_markers, codex_home).await? + { + let project_layers = load_project_layers(&cwd, &project_root).await?; + layers.extend(project_layers); + } } // Add a layer for runtime overrides from the CLI or UI, if any exist. - if !cli_overrides.is_empty() { - let cli_overrides_layer = overrides::build_cli_overrides_layer(cli_overrides); + if let Some(cli_overrides_layer) = cli_overrides_layer { layers.push(ConfigLayerEntry::new( ConfigLayerSource::SessionFlags, cli_overrides_layer, @@ -388,6 +402,44 @@ fn default_project_root_markers() -> Vec { .collect() } +async fn trusted_project_root( + merged_config: &TomlValue, + cwd: &AbsolutePathBuf, + project_root_markers: &[String], + config_base_dir: &Path, +) -> io::Result> { + let config_toml = deserialize_config_toml_with_base(merged_config.clone(), config_base_dir)?; + + let project_root = find_project_root(cwd, project_root_markers).await?; + let projects = config_toml.projects.unwrap_or_default(); + + let cwd_key = cwd.as_path().to_string_lossy().to_string(); + let project_root_key = project_root.as_path().to_string_lossy().to_string(); + let repo_root_key = resolve_root_git_project_for_trust(cwd.as_path()) + .map(|root| root.to_string_lossy().to_string()); + + let trust_level = projects + .get(&cwd_key) + .and_then(|project| project.trust_level) + .or_else(|| { + projects + .get(&project_root_key) + .and_then(|project| project.trust_level) + }) + .or_else(|| { + repo_root_key + .as_ref() + .and_then(|root| projects.get(root)) + .and_then(|project| project.trust_level) + }); + + if matches!(trust_level, Some(TrustLevel::Trusted)) { + Ok(Some(project_root)) + } else { + Ok(None) + } +} + /// Takes a `toml::Value` parsed from a config.toml file and walks through it, /// resolving any `AbsolutePathBuf` fields against `base_dir`, returning a new /// `toml::Value` with the same shape but with paths resolved. @@ -604,7 +656,7 @@ mod unit_tests { let contents = r#" # This is a field recognized by config.toml that is an AbsolutePathBuf in # the ConfigToml struct. -experimental_instructions_file = "./some_file.md" +model_instructions = "./some_file.md" # This is a field recognized by config.toml. model = "gpt-1000" @@ -617,7 +669,7 @@ foo = "xyzzy" let normalized_toml_value = resolve_relative_paths_in_config_toml(user_config, base_dir)?; let mut expected_toml_value = toml::map::Map::new(); expected_toml_value.insert( - "experimental_instructions_file".to_string(), + "model_instructions".to_string(), TomlValue::String( AbsolutePathBuf::resolve_path_against_base("./some_file.md", base_dir)? .as_path() diff --git a/codex-rs/core/src/config_loader/tests.rs b/codex-rs/core/src/config_loader/tests.rs index 3738f95b22..ff531dacb1 100644 --- a/codex-rs/core/src/config_loader/tests.rs +++ b/codex-rs/core/src/config_loader/tests.rs @@ -3,19 +3,47 @@ use super::load_config_layers_state; use crate::config::CONFIG_TOML_FILE; use crate::config::ConfigBuilder; use crate::config::ConfigOverrides; +use crate::config::ConfigToml; +use crate::config::ProjectConfig; use crate::config_loader::ConfigLayerEntry; use crate::config_loader::ConfigRequirements; use crate::config_loader::config_requirements::ConfigRequirementsWithSources; use crate::config_loader::fingerprint::version_for_toml; use crate::config_loader::load_requirements_toml; +use codex_protocol::config_types::TrustLevel; use codex_protocol::protocol::AskForApproval; #[cfg(target_os = "macos")] use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; +use std::collections::HashMap; +use std::path::Path; use tempfile::tempdir; use toml::Value as TomlValue; +async fn make_config_for_test( + codex_home: &Path, + project_path: &Path, + trust_level: TrustLevel, + project_root_markers: Option>, +) -> std::io::Result<()> { + tokio::fs::write( + codex_home.join(CONFIG_TOML_FILE), + toml::to_string(&ConfigToml { + projects: Some(HashMap::from([( + project_path.to_string_lossy().to_string(), + ProjectConfig { + trust_level: Some(trust_level), + }, + )])), + project_root_markers, + ..Default::default() + }) + .expect("serialize config"), + ) + .await +} + #[tokio::test] async fn merges_managed_config_layer_on_top() { let tmp = tempdir().expect("tempdir"); @@ -365,6 +393,7 @@ async fn project_layers_prefer_closest_cwd() -> std::io::Result<()> { let codex_home = tmp.path().join("home"); tokio::fs::create_dir_all(&codex_home).await?; + make_config_for_test(&codex_home, &project_root, TrustLevel::Trusted, None).await?; let cwd = AbsolutePathBuf::from_absolute_path(&nested)?; let layers = load_config_layers_state( &codex_home, @@ -409,10 +438,10 @@ async fn project_paths_resolve_relative_to_dot_codex_and_override_in_order() -> tokio::fs::write(project_root.join(".git"), "gitdir: here").await?; let root_cfg = r#" -experimental_instructions_file = "root.txt" +model_instructions = "root.txt" "#; let nested_cfg = r#" -experimental_instructions_file = "child.txt" +model_instructions = "child.txt" "#; tokio::fs::write(project_root.join(".codex").join(CONFIG_TOML_FILE), root_cfg).await?; tokio::fs::write(nested.join(".codex").join(CONFIG_TOML_FILE), nested_cfg).await?; @@ -429,6 +458,7 @@ experimental_instructions_file = "child.txt" let codex_home = tmp.path().join("home"); tokio::fs::create_dir_all(&codex_home).await?; + make_config_for_test(&codex_home, &project_root, TrustLevel::Trusted, None).await?; let config = ConfigBuilder::default() .codex_home(codex_home) @@ -458,6 +488,7 @@ async fn project_layer_is_added_when_dot_codex_exists_without_config_toml() -> s let codex_home = tmp.path().join("home"); tokio::fs::create_dir_all(&codex_home).await?; + make_config_for_test(&codex_home, &project_root, TrustLevel::Trusted, None).await?; let cwd = AbsolutePathBuf::from_absolute_path(&nested)?; let layers = load_config_layers_state( &codex_home, @@ -486,6 +517,95 @@ async fn project_layer_is_added_when_dot_codex_exists_without_config_toml() -> s Ok(()) } +#[tokio::test] +async fn project_layers_skipped_when_untrusted_or_unknown() -> std::io::Result<()> { + let tmp = tempdir()?; + let project_root = tmp.path().join("project"); + let nested = project_root.join("child"); + tokio::fs::create_dir_all(nested.join(".codex")).await?; + tokio::fs::write( + nested.join(".codex").join(CONFIG_TOML_FILE), + "foo = \"child\"\n", + ) + .await?; + + let cwd = AbsolutePathBuf::from_absolute_path(&nested)?; + + let codex_home_untrusted = tmp.path().join("home_untrusted"); + tokio::fs::create_dir_all(&codex_home_untrusted).await?; + make_config_for_test( + &codex_home_untrusted, + &project_root, + TrustLevel::Untrusted, + None, + ) + .await?; + + let layers_untrusted = load_config_layers_state( + &codex_home_untrusted, + Some(cwd.clone()), + &[] as &[(String, TomlValue)], + LoaderOverrides::default(), + ) + .await?; + let project_layers_untrusted = layers_untrusted + .layers_high_to_low() + .into_iter() + .filter(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. })) + .count(); + assert_eq!(project_layers_untrusted, 0); + assert_eq!(layers_untrusted.effective_config().get("foo"), None); + + let codex_home_unknown = tmp.path().join("home_unknown"); + tokio::fs::create_dir_all(&codex_home_unknown).await?; + + let layers_unknown = load_config_layers_state( + &codex_home_unknown, + Some(cwd), + &[] as &[(String, TomlValue)], + LoaderOverrides::default(), + ) + .await?; + let project_layers_unknown = layers_unknown + .layers_high_to_low() + .into_iter() + .filter(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. })) + .count(); + assert_eq!(project_layers_unknown, 0); + assert_eq!(layers_unknown.effective_config().get("foo"), None); + + Ok(()) +} + +#[tokio::test] +async fn cli_overrides_with_relative_paths_do_not_break_trust_check() -> std::io::Result<()> { + let tmp = tempdir()?; + let project_root = tmp.path().join("project"); + let nested = project_root.join("child"); + tokio::fs::create_dir_all(&nested).await?; + tokio::fs::write(project_root.join(".git"), "gitdir: here").await?; + + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + make_config_for_test(&codex_home, &project_root, TrustLevel::Trusted, None).await?; + + let cwd = AbsolutePathBuf::from_absolute_path(&nested)?; + let cli_overrides = vec![( + "model_instructions".to_string(), + TomlValue::String("relative.md".to_string()), + )]; + + load_config_layers_state( + &codex_home, + Some(cwd), + &cli_overrides, + LoaderOverrides::default(), + ) + .await?; + + Ok(()) +} + #[tokio::test] async fn project_root_markers_supports_alternate_markers() -> std::io::Result<()> { let tmp = tempdir()?; @@ -507,11 +627,11 @@ async fn project_root_markers_supports_alternate_markers() -> std::io::Result<() let codex_home = tmp.path().join("home"); tokio::fs::create_dir_all(&codex_home).await?; - tokio::fs::write( - codex_home.join(CONFIG_TOML_FILE), - r#" -project_root_markers = [".hg"] -"#, + make_config_for_test( + &codex_home, + &project_root, + TrustLevel::Trusted, + Some(vec![".hg".to_string()]), ) .await?; diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 4ee2e252c4..354b3c2d14 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -1,12 +1,13 @@ use crate::codex::TurnContext; use crate::context_manager::normalize; +use crate::instructions::SkillInstructions; +use crate::instructions::UserInstructions; +use crate::session_prefix::is_session_prefix; use crate::truncate::TruncationPolicy; use crate::truncate::approx_token_count; use crate::truncate::approx_tokens_from_byte_count; use crate::truncate::truncate_function_output_items_with_policy; use crate::truncate::truncate_text; -use crate::user_instructions::SkillInstructions; -use crate::user_instructions::UserInstructions; use crate::user_shell_command::is_user_shell_command_text; use codex_protocol::models::ContentItem; use codex_protocol::models::FunctionCallOutputContentItem; @@ -97,7 +98,11 @@ impl ContextManager { } | ResponseItem::Compaction { encrypted_content: content, - } => estimate_reasoning_length(content.len()) as i64, + } => { + let reasoning_bytes = estimate_reasoning_length(content.len()); + i64::try_from(approx_tokens_from_byte_count(reasoning_bytes)) + .unwrap_or(i64::MAX) + } item => { let serialized = serde_json::to_string(item).unwrap_or_default(); i64::try_from(approx_token_count(&serialized)).unwrap_or(i64::MAX) @@ -231,12 +236,19 @@ impl ContextManager { token_estimate as usize } - pub(crate) fn get_total_token_usage(&self) -> i64 { - self.token_info + /// When true, the server already accounted for past reasoning tokens and + /// the client should not re-estimate them. + pub(crate) fn get_total_token_usage(&self, server_reasoning_included: bool) -> i64 { + let last_tokens = self + .token_info .as_ref() .map(|info| info.last_token_usage.total_tokens) - .unwrap_or(0) - .saturating_add(self.get_non_last_reasoning_items_tokens() as i64) + .unwrap_or(0); + if server_reasoning_included { + last_tokens + } else { + last_tokens.saturating_add(self.get_non_last_reasoning_items_tokens() as i64) + } } /// This function enforces a couple of invariants on the in-memory history: @@ -317,12 +329,6 @@ fn estimate_reasoning_length(encoded_len: usize) -> usize { .saturating_sub(650) } -fn is_session_prefix(text: &str) -> bool { - let trimmed = text.trim_start(); - let lowered = trimmed.to_ascii_lowercase(); - lowered.starts_with("") -} - pub(crate) fn is_user_turn_boundary(item: &ResponseItem) -> bool { let ResponseItem::Message { role, content, .. } = item else { return false; diff --git a/codex-rs/core/src/default_client.rs b/codex-rs/core/src/default_client.rs index 4ded10a3d9..1a6e56e6c5 100644 --- a/codex-rs/core/src/default_client.rs +++ b/codex-rs/core/src/default_client.rs @@ -169,7 +169,8 @@ pub fn build_reqwest_client() -> reqwest::Client { let mut builder = reqwest::Client::builder() // Set UA via dedicated helper to avoid header validation pitfalls .user_agent(ua) - .default_headers(headers); + .default_headers(headers) + .use_rustls_tls(); if is_sandboxed() { builder = builder.no_proxy(); } diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index 6c19c726df..deee79ae70 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -17,16 +17,11 @@ use codex_protocol::user_input::UserInput; use tracing::warn; use uuid::Uuid; -use crate::user_instructions::SkillInstructions; -use crate::user_instructions::UserInstructions; +use crate::instructions::SkillInstructions; +use crate::instructions::UserInstructions; +use crate::session_prefix::is_session_prefix; use crate::user_shell_command::is_user_shell_command_text; -fn is_session_prefix(text: &str) -> bool { - let trimmed = text.trim_start(); - let lowered = trimmed.to_ascii_lowercase(); - lowered.starts_with("") -} - fn parse_user_message(message: &[ContentItem]) -> Option { if UserInstructions::is_user_instructions(message) || SkillInstructions::is_skill_instructions(message) @@ -52,7 +47,7 @@ fn parse_user_message(message: &[ContentItem]) -> Option { } content.push(UserInput::Text { text: text.clone(), - // Plain text conversion has no UI element ranges. + // Model input content does not carry UI element ranges. text_elements: Vec::new(), }); } diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 2a91873238..275f2fb856 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -47,6 +47,12 @@ const EXEC_TIMEOUT_EXIT_CODE: i32 = 124; // conventional timeout exit code const READ_CHUNK_SIZE: usize = 8192; // bytes per read const AGGREGATE_BUFFER_INITIAL_CAPACITY: usize = 8 * 1024; // 8 KiB +/// Hard cap on bytes retained from exec stdout/stderr/aggregated output. +/// +/// This mirrors unified exec's output cap so a single runaway command cannot +/// OOM the process by dumping huge amounts of data to stdout/stderr. +const EXEC_OUTPUT_MAX_BYTES: usize = 1024 * 1024; // 1 MiB + /// Limit the number of ExecCommandOutputDelta events emitted per exec call. /// Aggregation still collects full output; only the live event stream is capped. pub(crate) const MAX_EXEC_OUTPUT_DELTAS_PER_CALL: usize = 10_000; @@ -290,18 +296,32 @@ async fn exec_windows_sandbox( }; let exit_status = synthetic_exit_status(capture.exit_code); + let mut stdout_text = capture.stdout; + if stdout_text.len() > EXEC_OUTPUT_MAX_BYTES { + stdout_text.truncate(EXEC_OUTPUT_MAX_BYTES); + } + let mut stderr_text = capture.stderr; + if stderr_text.len() > EXEC_OUTPUT_MAX_BYTES { + stderr_text.truncate(EXEC_OUTPUT_MAX_BYTES); + } let stdout = StreamOutput { - text: capture.stdout, + text: stdout_text, truncated_after_lines: None, }; let stderr = StreamOutput { - text: capture.stderr, + text: stderr_text, truncated_after_lines: None, }; - // Best-effort aggregate: stdout then stderr - let mut aggregated = Vec::with_capacity(stdout.text.len() + stderr.text.len()); - append_all(&mut aggregated, &stdout.text); - append_all(&mut aggregated, &stderr.text); + // Best-effort aggregate: stdout then stderr (capped). + let mut aggregated = Vec::with_capacity( + stdout + .text + .len() + .saturating_add(stderr.text.len()) + .min(EXEC_OUTPUT_MAX_BYTES), + ); + append_capped(&mut aggregated, &stdout.text, EXEC_OUTPUT_MAX_BYTES); + append_capped(&mut aggregated, &stderr.text, EXEC_OUTPUT_MAX_BYTES); let aggregated_output = StreamOutput { text: aggregated, truncated_after_lines: None, @@ -490,8 +510,13 @@ impl StreamOutput> { } #[inline] -fn append_all(dst: &mut Vec, src: &[u8]) { - dst.extend_from_slice(src); +fn append_capped(dst: &mut Vec, src: &[u8], max_bytes: usize) { + if dst.len() >= max_bytes { + return; + } + let remaining = max_bytes.saturating_sub(dst.len()); + let take = remaining.min(src.len()); + dst.extend_from_slice(&src[..take]); } #[derive(Clone, Debug)] @@ -584,19 +609,15 @@ async fn consume_truncated_output( )) })?; - let (agg_tx, agg_rx) = async_channel::unbounded::>(); - let stdout_handle = tokio::spawn(read_capped( BufReader::new(stdout_reader), stdout_stream.clone(), false, - Some(agg_tx.clone()), )); let stderr_handle = tokio::spawn(read_capped( BufReader::new(stderr_reader), stdout_stream.clone(), true, - Some(agg_tx.clone()), )); let (exit_status, timed_out) = tokio::select! { @@ -662,15 +683,18 @@ async fn consume_truncated_output( Duration::from_millis(IO_DRAIN_TIMEOUT_MS), ) .await?; - - drop(agg_tx); - - let mut combined_buf = Vec::with_capacity(AGGREGATE_BUFFER_INITIAL_CAPACITY); - while let Ok(chunk) = agg_rx.recv().await { - append_all(&mut combined_buf, &chunk); - } + // Best-effort aggregate: stdout then stderr (capped). + let mut aggregated = Vec::with_capacity( + stdout + .text + .len() + .saturating_add(stderr.text.len()) + .min(EXEC_OUTPUT_MAX_BYTES), + ); + append_capped(&mut aggregated, &stdout.text, EXEC_OUTPUT_MAX_BYTES); + append_capped(&mut aggregated, &stderr.text, EXEC_OUTPUT_MAX_BYTES * 2); let aggregated_output = StreamOutput { - text: combined_buf, + text: aggregated, truncated_after_lines: None, }; @@ -687,14 +711,11 @@ async fn read_capped( mut reader: R, stream: Option, is_stderr: bool, - aggregate_tx: Option>>, ) -> io::Result>> { - let mut buf = Vec::with_capacity(AGGREGATE_BUFFER_INITIAL_CAPACITY); + let mut buf = Vec::with_capacity(AGGREGATE_BUFFER_INITIAL_CAPACITY.min(EXEC_OUTPUT_MAX_BYTES)); let mut tmp = [0u8; READ_CHUNK_SIZE]; let mut emitted_deltas: usize = 0; - // No caps: append all bytes - loop { let n = reader.read(&mut tmp).await?; if n == 0 { @@ -723,11 +744,7 @@ async fn read_capped( emitted_deltas += 1; } - if let Some(tx) = &aggregate_tx { - let _ = tx.send(tmp[..n].to_vec()).await; - } - - append_all(&mut buf, &tmp[..n]); + append_capped(&mut buf, &tmp[..n], EXEC_OUTPUT_MAX_BYTES); // Continue reading to EOF to avoid back-pressure } @@ -755,6 +772,7 @@ fn synthetic_exit_status(code: i32) -> ExitStatus { mod tests { use super::*; use std::time::Duration; + use tokio::io::AsyncWriteExt; fn make_exec_output( exit_code: i32, @@ -816,6 +834,18 @@ mod tests { )); } + #[tokio::test] + async fn read_capped_limits_retained_bytes() { + let (mut writer, reader) = tokio::io::duplex(1024); + let bytes = vec![b'a'; EXEC_OUTPUT_MAX_BYTES.saturating_add(128 * 1024)]; + tokio::spawn(async move { + writer.write_all(&bytes).await.expect("write"); + }); + + let out = read_capped(reader, None, false).await.expect("read"); + assert_eq!(out.text.len(), EXEC_OUTPUT_MAX_BYTES); + } + #[cfg(unix)] #[test] fn sandbox_detection_flags_sigsys_exit_code() { diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 2ea63f812a..0b1563bd5e 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -21,28 +21,33 @@ pub(crate) use legacy::legacy_feature_keys; /// High-level lifecycle stage for a feature. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Stage { - Experimental, - Beta { + /// Closed beta features to be used while developing or within the company. + Beta, + /// Experimental features made available to users through the `/experimental` menu + Experimental { name: &'static str, menu_description: &'static str, announcement: &'static str, }, + /// Stable features. The feature flag is kept for ad-hoc enabling/disabling Stable, + /// Deprecated feature that should not be used anymore. Deprecated, + /// The feature flag is useless but kept for backward compatibility reason. Removed, } impl Stage { pub fn beta_menu_name(self) -> Option<&'static str> { match self { - Stage::Beta { name, .. } => Some(name), + Stage::Experimental { name, .. } => Some(name), _ => None, } } pub fn beta_menu_description(self) -> Option<&'static str> { match self { - Stage::Beta { + Stage::Experimental { menu_description, .. } => Some(menu_description), _ => None, @@ -51,7 +56,7 @@ impl Stage { pub fn beta_announcement(self) -> Option<&'static str> { match self { - Stage::Beta { announcement, .. } => Some(announcement), + Stage::Experimental { announcement, .. } => Some(announcement), _ => None, } } @@ -100,6 +105,8 @@ pub enum Feature { Collab, /// Steer feature flag - when enabled, Enter submits immediately instead of queuing. Steer, + /// Enable collaboration modes (Plan, Pair Programming, Execute). + CollaborationModes, } impl Feature { @@ -334,14 +341,14 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::WebSearchCached, key: "web_search_cached", - stage: Stage::Experimental, + stage: Stage::Beta, default_enabled: false, }, // Beta program. Rendered in the `/experimental` menu for users. FeatureSpec { id: Feature::UnifiedExec, key: "unified_exec", - stage: Stage::Beta { + stage: Stage::Experimental { name: "Background terminal", menu_description: "Run long-running terminal commands in the background.", announcement: "NEW! Try Background terminals for long-running commands. Enable in /experimental!", @@ -351,7 +358,7 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::ShellSnapshot, key: "shell_snapshot", - stage: Stage::Beta { + stage: Stage::Experimental { name: "Shell snapshot", menu_description: "Snapshot your shell environment to avoid re-running login scripts for every command.", announcement: "NEW! Try shell snapshotting to make your Codex faster. Enable in /experimental!", @@ -361,50 +368,50 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::ChildAgentsMd, key: "child_agents_md", - stage: Stage::Experimental, + stage: Stage::Beta, default_enabled: false, }, FeatureSpec { id: Feature::ApplyPatchFreeform, key: "apply_patch_freeform", - stage: Stage::Experimental, + stage: Stage::Beta, default_enabled: false, }, FeatureSpec { id: Feature::ExecPolicy, key: "exec_policy", - stage: Stage::Experimental, + stage: Stage::Beta, default_enabled: true, }, FeatureSpec { id: Feature::WindowsSandbox, key: "experimental_windows_sandbox", - stage: Stage::Experimental, + stage: Stage::Beta, default_enabled: false, }, FeatureSpec { id: Feature::WindowsSandboxElevated, key: "elevated_windows_sandbox", - stage: Stage::Experimental, + stage: Stage::Beta, default_enabled: false, }, FeatureSpec { id: Feature::RemoteCompaction, key: "remote_compaction", - stage: Stage::Experimental, + stage: Stage::Beta, default_enabled: true, }, FeatureSpec { id: Feature::RemoteModels, key: "remote_models", - stage: Stage::Experimental, + stage: Stage::Beta, default_enabled: false, }, FeatureSpec { id: Feature::PowershellUtf8, key: "powershell_utf8", #[cfg(windows)] - stage: Stage::Beta { + stage: Stage::Experimental { name: "Powershell UTF-8 support", menu_description: "Enable UTF-8 output in Powershell.", announcement: "Codex now supports UTF-8 output in Powershell. If you are seeing problems, disable in /experimental.", @@ -412,36 +419,46 @@ pub const FEATURES: &[FeatureSpec] = &[ #[cfg(windows)] default_enabled: true, #[cfg(not(windows))] - stage: Stage::Experimental, + stage: Stage::Beta, #[cfg(not(windows))] default_enabled: false, }, FeatureSpec { id: Feature::EnableRequestCompression, key: "enable_request_compression", - stage: Stage::Experimental, + stage: Stage::Beta, default_enabled: false, }, FeatureSpec { id: Feature::Collab, key: "collab", - stage: Stage::Experimental, + stage: Stage::Experimental { + name: "Multi-agents", + menu_description: "Allow Codex to spawn and collaborate with other agents on request (formerly named `collab`).", + announcement: "NEW! Codex can now spawn other agents and work with them to solve your problems. Enable in /experimental!", + }, default_enabled: false, }, FeatureSpec { id: Feature::Tui2, key: "tui2", - stage: Stage::Experimental, + stage: Stage::Beta, default_enabled: false, }, FeatureSpec { id: Feature::Steer, key: "steer", - stage: Stage::Beta { + stage: Stage::Experimental { 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, }, + FeatureSpec { + id: Feature::CollaborationModes, + key: "collaboration_modes", + stage: Stage::Beta, + default_enabled: false, + }, ]; diff --git a/codex-rs/core/src/instructions/mod.rs b/codex-rs/core/src/instructions/mod.rs new file mode 100644 index 0000000000..9da925ac74 --- /dev/null +++ b/codex-rs/core/src/instructions/mod.rs @@ -0,0 +1,6 @@ +mod user_instructions; + +pub(crate) use user_instructions::SkillInstructions; +pub use user_instructions::USER_INSTRUCTIONS_OPEN_TAG_LEGACY; +pub use user_instructions::USER_INSTRUCTIONS_PREFIX; +pub(crate) use user_instructions::UserInstructions; diff --git a/codex-rs/core/src/user_instructions.rs b/codex-rs/core/src/instructions/user_instructions.rs similarity index 100% rename from codex-rs/core/src/user_instructions.rs rename to codex-rs/core/src/instructions/user_instructions.rs diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 498c45748f..1fc61a2926 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -31,6 +31,7 @@ mod exec_policy; pub mod features; mod flags; pub mod git_info; +pub mod instructions; pub mod landlock; pub mod mcp; mod mcp_connection_manager; @@ -45,12 +46,12 @@ pub mod parse_command; pub mod path_utils; pub mod powershell; pub mod sandboxing; +mod session_prefix; mod stream_events_utils; mod text_encoding; pub mod token_data; mod truncate; mod unified_exec; -mod user_instructions; pub mod windows_sandbox; pub use model_provider_info::CHAT_WIRE_API_DEPRECATION_SUMMARY; pub use model_provider_info::DEFAULT_LMSTUDIO_PORT; @@ -100,9 +101,11 @@ pub use rollout::find_conversation_path_by_id_str; pub use rollout::find_thread_path_by_id_str; pub use rollout::list::Cursor; pub use rollout::list::ThreadItem; +pub use rollout::list::ThreadSortKey; pub use rollout::list::ThreadsPage; pub use rollout::list::parse_cursor; pub use rollout::list::read_head_for_summary; +pub use rollout::list::read_session_meta_line; mod function_tool; mod state; mod tasks; diff --git a/codex-rs/core/src/models_manager/collaboration_mode_presets.rs b/codex-rs/core/src/models_manager/collaboration_mode_presets.rs new file mode 100644 index 0000000000..98ffcbd02d --- /dev/null +++ b/codex-rs/core/src/models_manager/collaboration_mode_presets.rs @@ -0,0 +1,42 @@ +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::Settings; +use codex_protocol::openai_models::ReasoningEffort; + +const COLLABORATION_MODE_PLAN: &str = include_str!("../../templates/collaboration_mode/plan.md"); +const COLLABORATION_MODE_PAIR_PROGRAMMING: &str = + include_str!("../../templates/collaboration_mode/pair_programming.md"); +const COLLABORATION_MODE_EXECUTE: &str = + include_str!("../../templates/collaboration_mode/execute.md"); + +pub(super) fn builtin_collaboration_mode_presets() -> Vec { + vec![plan_preset(), pair_programming_preset(), execute_preset()] +} + +#[cfg(any(test, feature = "test-support"))] +pub fn test_builtin_collaboration_mode_presets() -> Vec { + builtin_collaboration_mode_presets() +} + +fn plan_preset() -> CollaborationMode { + CollaborationMode::Plan(Settings { + model: "gpt-5.2-codex".to_string(), + reasoning_effort: Some(ReasoningEffort::Medium), + developer_instructions: Some(COLLABORATION_MODE_PLAN.to_string()), + }) +} + +fn pair_programming_preset() -> CollaborationMode { + CollaborationMode::PairProgramming(Settings { + model: "gpt-5.2-codex".to_string(), + reasoning_effort: Some(ReasoningEffort::Medium), + developer_instructions: Some(COLLABORATION_MODE_PAIR_PROGRAMMING.to_string()), + }) +} + +fn execute_preset() -> CollaborationMode { + CollaborationMode::Execute(Settings { + model: "gpt-5.2-codex".to_string(), + reasoning_effort: Some(ReasoningEffort::XHigh), + developer_instructions: Some(COLLABORATION_MODE_EXECUTE.to_string()), + }) +} diff --git a/codex-rs/core/src/models_manager/manager.rs b/codex-rs/core/src/models_manager/manager.rs index 206b4cab27..7210e1c62d 100644 --- a/codex-rs/core/src/models_manager/manager.rs +++ b/codex-rs/core/src/models_manager/manager.rs @@ -1,6 +1,20 @@ +use super::cache::ModelsCacheManager; +use crate::api_bridge::auth_provider_from_auth; +use crate::api_bridge::map_api_error; +use crate::auth::AuthManager; +use crate::config::Config; +use crate::default_client::build_reqwest_client; +use crate::error::CodexErr; +use crate::error::Result as CoreResult; +use crate::features::Feature; +use crate::model_provider_info::ModelProviderInfo; +use crate::models_manager::collaboration_mode_presets::builtin_collaboration_mode_presets; +use crate::models_manager::model_info; +use crate::models_manager::model_presets::builtin_model_presets; use codex_api::ModelsClient; use codex_api::ReqwestTransport; use codex_app_server_protocol::AuthMode; +use codex_protocol::config_types::CollaborationMode; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ModelsResponse; @@ -13,19 +27,6 @@ use tokio::sync::TryLockError; use tokio::time::timeout; use tracing::error; -use super::cache::ModelsCacheManager; -use crate::api_bridge::auth_provider_from_auth; -use crate::api_bridge::map_api_error; -use crate::auth::AuthManager; -use crate::config::Config; -use crate::default_client::build_reqwest_client; -use crate::error::CodexErr; -use crate::error::Result as CoreResult; -use crate::features::Feature; -use crate::model_provider_info::ModelProviderInfo; -use crate::models_manager::model_info; -use crate::models_manager::model_presets::builtin_model_presets; - const MODEL_CACHE_FILE: &str = "models_cache.json"; const DEFAULT_MODEL_CACHE_TTL: Duration = Duration::from_secs(300); const MODELS_REFRESH_TIMEOUT: Duration = Duration::from_secs(5); @@ -90,6 +91,13 @@ impl ModelsManager { self.build_available_models(remote_models) } + /// List collaboration mode presets. + /// + /// Returns a static set of presets seeded with the configured model. + pub fn list_collaboration_modes(&self) -> Vec { + builtin_collaboration_mode_presets() + } + /// Attempt to list models without blocking, using the current cached state. /// /// Returns an error if the internal lock cannot be acquired. @@ -202,6 +210,8 @@ impl ModelsManager { } async fn fetch_and_update_models(&self) -> CoreResult<()> { + let _timer = + codex_otel::start_global_timer("codex.remote_models.fetch_update.duration_ms", &[]); let auth = self.auth_manager.auth().await; let api_provider = self.provider.to_api_provider(Some(AuthMode::ChatGPT))?; let api_auth = auth_provider_from_auth(auth.clone(), &self.provider)?; @@ -229,7 +239,18 @@ impl ModelsManager { /// Replace the cached remote models and rebuild the derived presets list. async fn apply_remote_models(&self, models: Vec) { - *self.remote_models.write().await = models; + let mut existing_models = Self::load_remote_models_from_file().unwrap_or_default(); + for model in models { + if let Some(existing_index) = existing_models + .iter() + .position(|existing| existing.slug == model.slug) + { + existing_models[existing_index] = model; + } else { + existing_models.push(model); + } + } + *self.remote_models.write().await = existing_models; } fn load_remote_models_from_file() -> Result, std::io::Error> { @@ -240,6 +261,8 @@ impl ModelsManager { /// Attempt to satisfy the refresh from the cache when it matches the provider and TTL. async fn try_load_cache(&self) -> bool { + let _timer = + codex_otel::start_global_timer("codex.remote_models.load_cache.duration_ms", &[]); let cache = match self.cache_manager.load_fresh().await { Some(cache) => cache, None => return false, @@ -260,16 +283,16 @@ impl ModelsManager { let chatgpt_mode = self.auth_manager.get_auth_mode() == Some(AuthMode::ChatGPT); merged_presets = ModelPreset::filter_by_auth(merged_presets, chatgpt_mode); - let has_default = merged_presets.iter().any(|preset| preset.is_default); - if !has_default { - if let Some(default) = merged_presets - .iter_mut() - .find(|preset| preset.show_in_picker) - { - default.is_default = true; - } else if let Some(default) = merged_presets.first_mut() { - default.is_default = true; - } + for preset in &mut merged_presets { + preset.is_default = false; + } + if let Some(default) = merged_presets + .iter_mut() + .find(|preset| preset.show_in_picker) + { + default.is_default = true; + } else if let Some(default) = merged_presets.first_mut() { + default.is_default = true; } merged_presets @@ -384,6 +407,16 @@ mod tests { .expect("valid model") } + fn assert_models_contain(actual: &[ModelInfo], expected: &[ModelInfo]) { + for model in expected { + assert!( + actual.iter().any(|candidate| candidate.slug == model.slug), + "expected model {} in cached list", + model.slug + ); + } + } + fn provider_for(base_url: String) -> ModelProviderInfo { ModelProviderInfo { name: "mock".into(), @@ -403,7 +436,7 @@ mod tests { } #[tokio::test] - async fn refresh_available_models_sorts_and_marks_default() { + async fn refresh_available_models_sorts_by_priority() { let server = MockServer::start().await; let remote_models = vec![ remote_model("priority-low", "Low", 1), @@ -435,7 +468,7 @@ mod tests { .await .expect("refresh succeeds"); let cached_remote = manager.get_remote_models(&config).await; - assert_eq!(cached_remote, remote_models); + assert_models_contain(&cached_remote, &remote_models); let available = manager .list_models(&config, RefreshStrategy::OnlineIfUncached) @@ -452,11 +485,6 @@ mod tests { high_idx < low_idx, "higher priority should be listed before lower priority" ); - assert!( - available[high_idx].is_default, - "highest priority should be default" - ); - assert!(!available[low_idx].is_default); assert_eq!( models_mock.requests().len(), 1, @@ -496,22 +524,14 @@ mod tests { .refresh_available_models(&config, RefreshStrategy::OnlineIfUncached) .await .expect("first refresh succeeds"); - assert_eq!( - manager.get_remote_models(&config).await, - remote_models, - "remote cache should store fetched models" - ); + assert_models_contain(&manager.get_remote_models(&config).await, &remote_models); // Second call should read from cache and avoid the network. manager .refresh_available_models(&config, RefreshStrategy::OnlineIfUncached) .await .expect("cached refresh succeeds"); - assert_eq!( - manager.get_remote_models(&config).await, - remote_models, - "cache path should not mutate stored models" - ); + assert_models_contain(&manager.get_remote_models(&config).await, &remote_models); assert_eq!( models_mock.requests().len(), 1, @@ -575,11 +595,7 @@ mod tests { .refresh_available_models(&config, RefreshStrategy::OnlineIfUncached) .await .expect("second refresh succeeds"); - assert_eq!( - manager.get_remote_models(&config).await, - updated_models, - "stale cache should trigger refetch" - ); + assert_models_contain(&manager.get_remote_models(&config).await, &updated_models); assert_eq!( initial_mock.requests().len(), 1, diff --git a/codex-rs/core/src/models_manager/mod.rs b/codex-rs/core/src/models_manager/mod.rs index d0e3c8214a..3a649d5f14 100644 --- a/codex-rs/core/src/models_manager/mod.rs +++ b/codex-rs/core/src/models_manager/mod.rs @@ -1,4 +1,8 @@ pub mod cache; +pub mod collaboration_mode_presets; pub mod manager; pub mod model_info; pub mod model_presets; + +#[cfg(any(test, feature = "test-support"))] +pub use collaboration_mode_presets::test_builtin_collaboration_mode_presets; diff --git a/codex-rs/core/src/models_manager/model_info.rs b/codex-rs/core/src/models_manager/model_info.rs index 5feeb6e12a..b2b0b0a909 100644 --- a/codex-rs/core/src/models_manager/model_info.rs +++ b/codex-rs/core/src/models_manager/model_info.rs @@ -9,6 +9,7 @@ use codex_protocol::openai_models::TruncationMode; use codex_protocol::openai_models::TruncationPolicyConfig; use crate::config::Config; +use crate::config::types::Personality; use crate::truncate::approx_bytes_for_tokens; use tracing::warn; @@ -86,6 +87,23 @@ pub(crate) fn with_config_overrides(mut model: ModelInfo, config: &Config) -> Mo } }; } + if let Some(base_instructions) = &config.base_instructions { + model.base_instructions = base_instructions.clone(); + } else if model.slug.contains("gpt-5.2-codex") + && let Some(personality) = &config.model_personality + { + let template = include_str!( + "../../templates/model_instructions/gpt-5.2-codex_instructions_template.md" + ); + let personality_message = match personality { + Personality::Friendly => include_str!("../../templates/personalities/friendly.md"), + Personality::Pragmatic => { + include_str!("../../templates/personalities/pragmatic.md") + } + }; + let instructions = template.replace("{{ personality_message }}", personality_message); + model.base_instructions = instructions; + } model } diff --git a/codex-rs/core/src/path_utils.rs b/codex-rs/core/src/path_utils.rs index 65b3db0f6d..4885679e52 100644 --- a/codex-rs/core/src/path_utils.rs +++ b/codex-rs/core/src/path_utils.rs @@ -1,5 +1,9 @@ +use codex_utils_absolute_path::AbsolutePathBuf; +use std::collections::HashSet; +use std::io; use std::path::Path; use std::path::PathBuf; +use tempfile::NamedTempFile; use crate::env; @@ -8,6 +12,106 @@ pub fn normalize_for_path_comparison(path: impl AsRef) -> std::io::Result< Ok(normalize_for_wsl(canonical)) } +pub struct SymlinkWritePaths { + pub read_path: Option, + pub write_path: PathBuf, +} + +/// Resolve the final filesystem target for `path` while retaining a safe write path. +/// +/// This follows symlink chains (including relative symlink targets) until it reaches a +/// non-symlink path. If the chain cycles or any metadata/link resolution fails, it +/// returns `read_path: None` and uses the original absolute path as `write_path`. +/// There is no fixed max-resolution count; cycles are detected via a visited set. +pub fn resolve_symlink_write_paths(path: &Path) -> io::Result { + let root = AbsolutePathBuf::from_absolute_path(path) + .map(AbsolutePathBuf::into_path_buf) + .unwrap_or_else(|_| path.to_path_buf()); + let mut current = root.clone(); + let mut visited = HashSet::new(); + + // Follow symlink chains while guarding against cycles. + loop { + let meta = match std::fs::symlink_metadata(¤t) { + Ok(meta) => meta, + Err(err) if err.kind() == io::ErrorKind::NotFound => { + return Ok(SymlinkWritePaths { + read_path: Some(current.clone()), + write_path: current, + }); + } + Err(_) => { + return Ok(SymlinkWritePaths { + read_path: None, + write_path: root, + }); + } + }; + + if !meta.file_type().is_symlink() { + return Ok(SymlinkWritePaths { + read_path: Some(current.clone()), + write_path: current, + }); + } + + // If we've already seen this path, the chain cycles. + if !visited.insert(current.clone()) { + return Ok(SymlinkWritePaths { + read_path: None, + write_path: root, + }); + } + + let target = match std::fs::read_link(¤t) { + Ok(target) => target, + Err(_) => { + return Ok(SymlinkWritePaths { + read_path: None, + write_path: root, + }); + } + }; + + let next = if target.is_absolute() { + AbsolutePathBuf::from_absolute_path(&target) + } else if let Some(parent) = current.parent() { + AbsolutePathBuf::resolve_path_against_base(&target, parent) + } else { + return Ok(SymlinkWritePaths { + read_path: None, + write_path: root, + }); + }; + + let next = match next { + Ok(path) => path.into_path_buf(), + Err(_) => { + return Ok(SymlinkWritePaths { + read_path: None, + write_path: root, + }); + } + }; + + current = next; + } +} + +pub fn write_atomically(write_path: &Path, contents: &str) -> io::Result<()> { + let parent = write_path.parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("path {} has no parent directory", write_path.display()), + ) + })?; + std::fs::create_dir_all(parent)?; + let tmp = NamedTempFile::new_in(parent)?; + std::fs::write(tmp.path(), contents)?; + tmp.persist(write_path)?; + Ok(()) +} + fn normalize_for_wsl(path: PathBuf) -> PathBuf { normalize_for_wsl_with_flag(path, env::is_wsl()) } @@ -84,6 +188,29 @@ fn lower_ascii_path(path: PathBuf) -> PathBuf { #[cfg(test)] mod tests { + #[cfg(unix)] + mod symlinks { + use super::super::resolve_symlink_write_paths; + use pretty_assertions::assert_eq; + use std::os::unix::fs::symlink; + + #[test] + fn symlink_cycles_fall_back_to_root_write_path() -> std::io::Result<()> { + let dir = tempfile::tempdir()?; + let a = dir.path().join("a"); + let b = dir.path().join("b"); + + symlink(&b, &a)?; + symlink(&a, &b)?; + + let resolved = resolve_symlink_write_paths(&a)?; + + assert_eq!(resolved.read_path, None); + assert_eq!(resolved.write_path, a); + Ok(()) + } + } + #[cfg(target_os = "linux")] mod wsl { use super::super::normalize_for_wsl_with_flag; diff --git a/codex-rs/core/src/rollout/list.rs b/codex-rs/core/src/rollout/list.rs index 487304ddc8..01e7b1733d 100644 --- a/codex-rs/core/src/rollout/list.rs +++ b/codex-rs/core/src/rollout/list.rs @@ -1,11 +1,13 @@ use std::cmp::Reverse; use std::io::{self}; use std::num::NonZero; +use std::ops::ControlFlow; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicBool; +use async_trait::async_trait; use time::OffsetDateTime; use time::PrimitiveDateTime; use time::format_description::FormatItem; @@ -18,6 +20,7 @@ use crate::protocol::EventMsg; use codex_file_search as file_search; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; +use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::SessionSource; /// Returned page of thread (thread) summaries. @@ -41,8 +44,10 @@ pub struct ThreadItem { /// First up to `HEAD_RECORD_LIMIT` JSONL records parsed as JSON (includes meta line). pub head: Vec, /// RFC3339 timestamp string for when the session was created, if available. + /// created_at comes from the filename timestamp with second precision. pub created_at: Option, /// RFC3339 timestamp string for the most recent update (from file mtime). + /// updated_at is truncated to second precision to match created_at. pub updated_at: Option, } @@ -68,6 +73,12 @@ struct HeadTailSummary { const MAX_SCAN_FILES: usize = 10000; const HEAD_RECORD_LIMIT: usize = 10; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ThreadSortKey { + CreatedAt, + UpdatedAt, +} + /// Pagination cursor identifying a file by timestamp and UUID. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Cursor { @@ -81,6 +92,135 @@ impl Cursor { } } +/// Keeps track of where a paginated listing left off. As the file scan goes newest -> oldest, +/// it ignores everything until it reaches the last seen item from the previous page, then +/// starts returning results after that. This makes paging stable even if new files show up during +/// pagination. +struct AnchorState { + ts: OffsetDateTime, + id: Uuid, + passed: bool, +} + +impl AnchorState { + fn new(anchor: Option) -> Self { + match anchor { + Some(cursor) => Self { + ts: cursor.ts, + id: cursor.id, + passed: false, + }, + None => Self { + ts: OffsetDateTime::UNIX_EPOCH, + id: Uuid::nil(), + passed: true, + }, + } + } + + fn should_skip(&mut self, ts: OffsetDateTime, id: Uuid) -> bool { + if self.passed { + return false; + } + if ts < self.ts || (ts == self.ts && id < self.id) { + self.passed = true; + false + } else { + true + } + } +} + +/// Visitor interface to customize behavior when visiting each rollout file +/// in `walk_rollout_files`. +/// +/// We need to apply different logic if we're ultimately going to be returning +/// threads ordered by created_at or updated_at. +#[async_trait] +trait RolloutFileVisitor { + async fn visit( + &mut self, + ts: OffsetDateTime, + id: Uuid, + path: PathBuf, + scanned: usize, + ) -> ControlFlow<()>; +} + +/// Collects thread items during directory traversal in created_at order, +/// applying pagination and filters inline. +struct FilesByCreatedAtVisitor<'a> { + items: &'a mut Vec, + page_size: usize, + anchor_state: AnchorState, + more_matches_available: bool, + allowed_sources: &'a [SessionSource], + provider_matcher: Option<&'a ProviderMatcher<'a>>, +} + +#[async_trait] +impl<'a> RolloutFileVisitor for FilesByCreatedAtVisitor<'a> { + async fn visit( + &mut self, + ts: OffsetDateTime, + id: Uuid, + path: PathBuf, + scanned: usize, + ) -> ControlFlow<()> { + if scanned >= MAX_SCAN_FILES && self.items.len() >= self.page_size { + self.more_matches_available = true; + return ControlFlow::Break(()); + } + if self.anchor_state.should_skip(ts, id) { + return ControlFlow::Continue(()); + } + if self.items.len() == self.page_size { + self.more_matches_available = true; + return ControlFlow::Break(()); + } + let updated_at = file_modified_time(&path) + .await + .unwrap_or(None) + .and_then(format_rfc3339); + if let Some(item) = build_thread_item( + path, + self.allowed_sources, + self.provider_matcher, + updated_at, + ) + .await + { + self.items.push(item); + } + ControlFlow::Continue(()) + } +} + +/// Collects lightweight file candidates (path + id + mtime). +/// Sorting after mtime happens after all files are collected. +struct FilesByUpdatedAtVisitor<'a> { + candidates: &'a mut Vec, +} + +#[async_trait] +impl<'a> RolloutFileVisitor for FilesByUpdatedAtVisitor<'a> { + async fn visit( + &mut self, + _ts: OffsetDateTime, + id: Uuid, + path: PathBuf, + _scanned: usize, + ) -> ControlFlow<()> { + let updated_at = file_modified_time(&path).await.unwrap_or(None); + self.candidates.push(ThreadCandidate { + path, + id, + updated_at, + }); + ControlFlow::Continue(()) + } +} + impl serde::Serialize for Cursor { fn serialize(&self, serializer: S) -> Result where @@ -108,11 +248,13 @@ impl<'de> serde::Deserialize<'de> for Cursor { /// Retrieve recorded thread file paths with token pagination. The returned `next_cursor` /// can be supplied on the next call to resume after the last returned item, resilient to -/// concurrent new sessions being appended. Ordering is stable by timestamp desc, then UUID desc. +/// concurrent new sessions being appended. Ordering is stable by the requested sort key +/// (timestamp desc, then UUID desc). pub(crate) async fn get_threads( codex_home: &Path, page_size: usize, cursor: Option<&Cursor>, + sort_key: ThreadSortKey, allowed_sources: &[SessionSource], model_providers: Option<&[String]>, default_provider: &str, @@ -138,6 +280,7 @@ pub(crate) async fn get_threads( root.clone(), page_size, anchor, + sort_key, allowed_sources, provider_matcher.as_ref(), ) @@ -148,8 +291,46 @@ pub(crate) async fn get_threads( /// Load thread file paths from disk using directory traversal. /// /// Directory layout: `~/.codex/sessions/YYYY/MM/DD/rollout-YYYY-MM-DDThh-mm-ss-.jsonl` -/// Returned newest (latest) first. +/// Returned newest (based on sort key) first. async fn traverse_directories_for_paths( + root: PathBuf, + page_size: usize, + anchor: Option, + sort_key: ThreadSortKey, + allowed_sources: &[SessionSource], + provider_matcher: Option<&ProviderMatcher<'_>>, +) -> io::Result { + match sort_key { + ThreadSortKey::CreatedAt => { + traverse_directories_for_paths_created( + root, + page_size, + anchor, + allowed_sources, + provider_matcher, + ) + .await + } + ThreadSortKey::UpdatedAt => { + traverse_directories_for_paths_updated( + root, + page_size, + anchor, + allowed_sources, + provider_matcher, + ) + .await + } + } +} + +/// Walk the rollout directory tree in reverse chronological order and +/// collect items until the page fills or the scan cap is hit. +/// +/// Ordering comes from directory/filename sorting, so created_at is derived +/// from the filename timestamp. Pagination is handled by the anchor cursor +/// so we resume strictly after the last returned `(ts, id)` pair. +async fn traverse_directories_for_paths_created( root: PathBuf, page_size: usize, anchor: Option, @@ -158,98 +339,17 @@ async fn traverse_directories_for_paths( ) -> io::Result { let mut items: Vec = Vec::with_capacity(page_size); let mut scanned_files = 0usize; - let mut anchor_passed = anchor.is_none(); - let (anchor_ts, anchor_id) = match anchor { - Some(c) => (c.ts, c.id), - None => (OffsetDateTime::UNIX_EPOCH, Uuid::nil()), - }; let mut more_matches_available = false; - - let year_dirs = collect_dirs_desc(&root, |s| s.parse::().ok()).await?; - - 'outer: for (_year, year_path) in year_dirs.iter() { - if scanned_files >= MAX_SCAN_FILES { - break; - } - let month_dirs = collect_dirs_desc(year_path, |s| s.parse::().ok()).await?; - for (_month, month_path) in month_dirs.iter() { - if scanned_files >= MAX_SCAN_FILES { - break 'outer; - } - let day_dirs = collect_dirs_desc(month_path, |s| s.parse::().ok()).await?; - for (_day, day_path) in day_dirs.iter() { - if scanned_files >= MAX_SCAN_FILES { - break 'outer; - } - let mut day_files = collect_files(day_path, |name_str, path| { - if !name_str.starts_with("rollout-") || !name_str.ends_with(".jsonl") { - return None; - } - - parse_timestamp_uuid_from_filename(name_str) - .map(|(ts, id)| (ts, id, name_str.to_string(), path.to_path_buf())) - }) - .await?; - // Stable ordering within the same second: (timestamp desc, uuid desc) - day_files.sort_by_key(|(ts, sid, _name_str, _path)| (Reverse(*ts), Reverse(*sid))); - for (ts, sid, _name_str, path) in day_files.into_iter() { - scanned_files += 1; - if scanned_files >= MAX_SCAN_FILES && items.len() >= page_size { - more_matches_available = true; - break 'outer; - } - if !anchor_passed { - if ts < anchor_ts || (ts == anchor_ts && sid < anchor_id) { - anchor_passed = true; - } else { - continue; - } - } - if items.len() == page_size { - more_matches_available = true; - break 'outer; - } - // Read head and detect message events; stop once meta + user are found. - let summary = read_head_summary(&path, HEAD_RECORD_LIMIT) - .await - .unwrap_or_default(); - if !allowed_sources.is_empty() - && !summary - .source - .is_some_and(|source| allowed_sources.iter().any(|s| s == &source)) - { - continue; - } - if let Some(matcher) = provider_matcher - && !matcher.matches(summary.model_provider.as_deref()) - { - continue; - } - // Apply filters: must have session meta and at least one user message event - if summary.saw_session_meta && summary.saw_user_event { - let HeadTailSummary { - head, - created_at, - mut updated_at, - .. - } = summary; - if updated_at.is_none() { - updated_at = file_modified_rfc3339(&path) - .await - .unwrap_or(None) - .or_else(|| created_at.clone()); - } - items.push(ThreadItem { - path, - head, - created_at, - updated_at, - }); - } - } - } - } - } + let mut visitor = FilesByCreatedAtVisitor { + items: &mut items, + page_size, + anchor_state: AnchorState::new(anchor), + more_matches_available, + allowed_sources, + provider_matcher, + }; + walk_rollout_files(&root, &mut scanned_files, &mut visitor).await?; + more_matches_available = visitor.more_matches_available; let reached_scan_cap = scanned_files >= MAX_SCAN_FILES; if reached_scan_cap && !items.is_empty() { @@ -257,7 +357,7 @@ async fn traverse_directories_for_paths( } let next = if more_matches_available { - build_next_cursor(&items) + build_next_cursor(&items, ThreadSortKey::CreatedAt) } else { None }; @@ -269,9 +369,77 @@ async fn traverse_directories_for_paths( }) } -/// Pagination cursor token format: "|" where `file_ts` matches the -/// filename timestamp portion (YYYY-MM-DDThh-mm-ss) used in rollout filenames. -/// The cursor orders files by timestamp desc, then UUID desc. +/// Walk the rollout directory tree to collect files by updated_at, then sort by +/// file mtime (updated_at) and apply pagination/filtering in that order. +/// +/// Because updated_at is not encoded in filenames, this path must scan all +/// files up to the scan cap, then sort and filter by the anchor cursor. +/// +/// NOTE: This can be optimized in the future if we store additional state on disk +/// to cache updated_at timestamps. +async fn traverse_directories_for_paths_updated( + root: PathBuf, + page_size: usize, + anchor: Option, + allowed_sources: &[SessionSource], + provider_matcher: Option<&ProviderMatcher<'_>>, +) -> io::Result { + let mut items: Vec = Vec::with_capacity(page_size); + let mut scanned_files = 0usize; + let mut anchor_state = AnchorState::new(anchor); + let mut more_matches_available = false; + + let candidates = collect_files_by_updated_at(&root, &mut scanned_files).await?; + let mut candidates = candidates; + candidates.sort_by_key(|candidate| { + let ts = candidate.updated_at.unwrap_or(OffsetDateTime::UNIX_EPOCH); + (Reverse(ts), Reverse(candidate.id)) + }); + + for candidate in candidates.into_iter() { + let ts = candidate.updated_at.unwrap_or(OffsetDateTime::UNIX_EPOCH); + if anchor_state.should_skip(ts, candidate.id) { + continue; + } + if items.len() == page_size { + more_matches_available = true; + break; + } + + let updated_at_fallback = candidate.updated_at.and_then(format_rfc3339); + if let Some(item) = build_thread_item( + candidate.path, + allowed_sources, + provider_matcher, + updated_at_fallback, + ) + .await + { + items.push(item); + } + } + + let reached_scan_cap = scanned_files >= MAX_SCAN_FILES; + if reached_scan_cap && !items.is_empty() { + more_matches_available = true; + } + + let next = if more_matches_available { + build_next_cursor(&items, ThreadSortKey::UpdatedAt) + } else { + None + }; + Ok(ThreadsPage { + items, + next_cursor: next, + num_scanned_files: scanned_files, + reached_scan_cap, + }) +} + +/// Pagination cursor token format: "|" where `ts` uses +/// YYYY-MM-DDThh-mm-ss (UTC, second precision). +/// The cursor orders files by the requested sort key (timestamp desc, then UUID desc). pub fn parse_cursor(token: &str) -> Option { let (file_ts, uuid_str) = token.split_once('|')?; @@ -286,13 +454,63 @@ pub fn parse_cursor(token: &str) -> Option { Some(Cursor::new(ts, uuid)) } -fn build_next_cursor(items: &[ThreadItem]) -> Option { +fn build_next_cursor(items: &[ThreadItem], sort_key: ThreadSortKey) -> Option { let last = items.last()?; let file_name = last.path.file_name()?.to_string_lossy(); - let (ts, id) = parse_timestamp_uuid_from_filename(&file_name)?; + let (created_ts, id) = parse_timestamp_uuid_from_filename(&file_name)?; + let ts = match sort_key { + ThreadSortKey::CreatedAt => created_ts, + ThreadSortKey::UpdatedAt => { + let updated_at = last.updated_at.as_deref()?; + OffsetDateTime::parse(updated_at, &Rfc3339).ok()? + } + }; Some(Cursor::new(ts, id)) } +async fn build_thread_item( + path: PathBuf, + allowed_sources: &[SessionSource], + provider_matcher: Option<&ProviderMatcher<'_>>, + updated_at: Option, +) -> Option { + // Read head and detect message events; stop once meta + user are found. + let summary = read_head_summary(&path, HEAD_RECORD_LIMIT) + .await + .unwrap_or_default(); + if !allowed_sources.is_empty() + && !summary + .source + .is_some_and(|source| allowed_sources.contains(&source)) + { + return None; + } + if let Some(matcher) = provider_matcher + && !matcher.matches(summary.model_provider.as_deref()) + { + return None; + } + // Apply filters: must have session meta and at least one user message event + if summary.saw_session_meta && summary.saw_user_event { + let HeadTailSummary { + head, + created_at, + updated_at: mut summary_updated_at, + .. + } = summary; + if summary_updated_at.is_none() { + summary_updated_at = updated_at.or_else(|| created_at.clone()); + } + return Some(ThreadItem { + path, + head, + created_at, + updated_at: summary_updated_at, + }); + } + None +} + /// Collects immediate subdirectories of `parent`, parses their (string) names with `parse`, /// and returns them sorted descending by the parsed key. async fn collect_dirs_desc(parent: &Path, parse: F) -> io::Result> @@ -340,6 +558,22 @@ where Ok(collected) } +async fn collect_rollout_day_files( + day_path: &Path, +) -> io::Result> { + let mut day_files = collect_files(day_path, |name_str, path| { + if !name_str.starts_with("rollout-") || !name_str.ends_with(".jsonl") { + return None; + } + + parse_timestamp_uuid_from_filename(name_str).map(|(ts, id)| (ts, id, path.to_path_buf())) + }) + .await?; + // Stable ordering within the same second: (timestamp desc, uuid desc) + day_files.sort_by_key(|(ts, sid, _path)| (Reverse(*ts), Reverse(*sid))); + Ok(day_files) +} + fn parse_timestamp_uuid_from_filename(name: &str) -> Option<(OffsetDateTime, Uuid)> { // Expected: rollout-YYYY-MM-DDThh-mm-ss-.jsonl let core = name.strip_prefix("rollout-")?.strip_suffix(".jsonl")?; @@ -357,6 +591,65 @@ fn parse_timestamp_uuid_from_filename(name: &str) -> Option<(OffsetDateTime, Uui Some((ts, uuid)) } +struct ThreadCandidate { + path: PathBuf, + id: Uuid, + updated_at: Option, +} + +async fn collect_files_by_updated_at( + root: &Path, + scanned_files: &mut usize, +) -> io::Result> { + let mut candidates = Vec::new(); + let mut visitor = FilesByUpdatedAtVisitor { + candidates: &mut candidates, + }; + walk_rollout_files(root, scanned_files, &mut visitor).await?; + + Ok(candidates) +} + +async fn walk_rollout_files( + root: &Path, + scanned_files: &mut usize, + visitor: &mut impl RolloutFileVisitor, +) -> io::Result<()> { + let year_dirs = collect_dirs_desc(root, |s| s.parse::().ok()).await?; + + 'outer: for (_year, year_path) in year_dirs.iter() { + if *scanned_files >= MAX_SCAN_FILES { + break; + } + let month_dirs = collect_dirs_desc(year_path, |s| s.parse::().ok()).await?; + for (_month, month_path) in month_dirs.iter() { + if *scanned_files >= MAX_SCAN_FILES { + break 'outer; + } + let day_dirs = collect_dirs_desc(month_path, |s| s.parse::().ok()).await?; + for (_day, day_path) in day_dirs.iter() { + if *scanned_files >= MAX_SCAN_FILES { + break 'outer; + } + let day_files = collect_rollout_day_files(day_path).await?; + for (ts, id, path) in day_files.into_iter() { + *scanned_files += 1; + if *scanned_files > MAX_SCAN_FILES { + break 'outer; + } + if let ControlFlow::Break(()) = + visitor.visit(ts, id, path, *scanned_files).await + { + break 'outer; + } + } + } + } + } + + Ok(()) +} + struct ProviderMatcher<'a> { filters: &'a [String], matches_default_provider: bool, @@ -452,14 +745,42 @@ pub async fn read_head_for_summary(path: &Path) -> io::Result io::Result> { +/// Read the SessionMetaLine from the head of a rollout file for reuse by +/// callers that need the session metadata (e.g. to derive a cwd for config). +pub async fn read_session_meta_line(path: &Path) -> io::Result { + let head = read_head_for_summary(path).await?; + let Some(first) = head.first() else { + return Err(io::Error::other(format!( + "rollout at {} is empty", + path.display() + ))); + }; + serde_json::from_value::(first.clone()).map_err(|_| { + io::Error::other(format!( + "rollout at {} does not start with session metadata", + path.display() + )) + }) +} + +async fn file_modified_time(path: &Path) -> io::Result> { let meta = tokio::fs::metadata(path).await?; let modified = meta.modified().ok(); let Some(modified) = modified else { return Ok(None); }; let dt = OffsetDateTime::from(modified); - Ok(dt.format(&Rfc3339).ok()) + // Truncate to seconds so ordering and cursor comparisons align with the + // cursor timestamp format (which exposes seconds), keeping pagination stable. + Ok(truncate_to_seconds(dt)) +} + +fn format_rfc3339(dt: OffsetDateTime) -> Option { + dt.format(&Rfc3339).ok() +} + +fn truncate_to_seconds(dt: OffsetDateTime) -> Option { + dt.replace_nanosecond(0).ok() } /// Locate a recorded thread rollout file by its UUID string using the existing diff --git a/codex-rs/core/src/rollout/policy.rs b/codex-rs/core/src/rollout/policy.rs index fafdc83102..2d4a79cc22 100644 --- a/codex-rs/core/src/rollout/policy.rs +++ b/codex-rs/core/src/rollout/policy.rs @@ -67,6 +67,7 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool { | EventMsg::ExecCommandOutputDelta(_) | EventMsg::ExecCommandEnd(_) | EventMsg::ExecApprovalRequest(_) + | EventMsg::RequestUserInput(_) | EventMsg::ElicitationRequest(_) | EventMsg::ApplyPatchApprovalRequest(_) | EventMsg::BackgroundEvent(_) diff --git a/codex-rs/core/src/rollout/recorder.rs b/codex-rs/core/src/rollout/recorder.rs index 7151f95b95..c5bd14b3c1 100644 --- a/codex-rs/core/src/rollout/recorder.rs +++ b/codex-rs/core/src/rollout/recorder.rs @@ -1,14 +1,13 @@ //! Persist Codex session rollouts (.jsonl) so sessions can be replayed or inspected later. use std::fs::File; -use std::fs::FileTimes; use std::fs::{self}; use std::io::Error as IoError; use std::path::Path; use std::path::PathBuf; -use std::time::SystemTime; use codex_protocol::ThreadId; +use codex_protocol::models::BaseInstructions; use serde_json::Value; use time::OffsetDateTime; use time::format_description::FormatItem; @@ -22,6 +21,7 @@ use tracing::warn; use super::SESSIONS_SUBDIR; use super::list::Cursor; +use super::list::ThreadSortKey; use super::list::ThreadsPage; use super::list::get_threads; use super::policy::is_persisted_response_item; @@ -56,8 +56,9 @@ pub struct RolloutRecorder { pub enum RolloutRecorderParams { Create { conversation_id: ThreadId, - instructions: Option, + forked_from_id: Option, source: SessionSource, + base_instructions: BaseInstructions, }, Resume { path: PathBuf, @@ -78,13 +79,15 @@ enum RolloutCmd { impl RolloutRecorderParams { pub fn new( conversation_id: ThreadId, - instructions: Option, + forked_from_id: Option, source: SessionSource, + base_instructions: BaseInstructions, ) -> Self { Self::Create { conversation_id, - instructions, + forked_from_id, source, + base_instructions, } } @@ -99,6 +102,7 @@ impl RolloutRecorder { codex_home: &Path, page_size: usize, cursor: Option<&Cursor>, + sort_key: ThreadSortKey, allowed_sources: &[SessionSource], model_providers: Option<&[String]>, default_provider: &str, @@ -107,6 +111,7 @@ impl RolloutRecorder { codex_home, page_size, cursor, + sort_key, allowed_sources, model_providers, default_provider, @@ -115,19 +120,24 @@ impl RolloutRecorder { } /// Find the newest recorded thread path, optionally filtering to a matching cwd. + #[allow(clippy::too_many_arguments)] pub async fn find_latest_thread_path( codex_home: &Path, + page_size: usize, + cursor: Option<&Cursor>, + sort_key: ThreadSortKey, allowed_sources: &[SessionSource], model_providers: Option<&[String]>, default_provider: &str, filter_cwd: Option<&Path>, ) -> std::io::Result> { - let mut cursor: Option = None; + let mut cursor = cursor.cloned(); loop { let page = Self::list_threads( codex_home, - 25, + page_size, cursor.as_ref(), + sort_key, allowed_sources, model_providers, default_provider, @@ -150,8 +160,9 @@ impl RolloutRecorder { let (file, rollout_path, meta) = match params { RolloutRecorderParams::Create { conversation_id, - instructions, + forked_from_id, source, + base_instructions, } => { let LogFileInfo { file, @@ -173,27 +184,25 @@ impl RolloutRecorder { path, Some(SessionMeta { id: session_id, + forked_from_id, timestamp, cwd: config.cwd.clone(), originator: originator().value, cli_version: env!("CARGO_PKG_VERSION").to_string(), - instructions, source, model_provider: Some(config.model_provider_id.clone()), + base_instructions: Some(base_instructions), }), ) } - RolloutRecorderParams::Resume { path } => { - touch_rollout_file(&path)?; - ( - tokio::fs::OpenOptions::new() - .append(true) - .open(&path) - .await?, - path, - None, - ) - } + RolloutRecorderParams::Resume { path } => ( + tokio::fs::OpenOptions::new() + .append(true) + .open(&path) + .await?, + path, + None, + ), }; // Clone the cwd for the spawned task to collect git info asynchronously @@ -378,13 +387,6 @@ fn create_log_file(config: &Config, conversation_id: ThreadId) -> std::io::Resul }) } -fn touch_rollout_file(path: &Path) -> std::io::Result<()> { - let file = fs::OpenOptions::new().append(true).open(path)?; - let times = FileTimes::new().set_modified(SystemTime::now()); - file.set_times(times)?; - Ok(()) -} - async fn rollout_writer( file: tokio::fs::File, mut rx: mpsc::Receiver, diff --git a/codex-rs/core/src/rollout/tests.rs b/codex-rs/core/src/rollout/tests.rs index c16ec65cf8..08d4a1e2c9 100644 --- a/codex-rs/core/src/rollout/tests.rs +++ b/codex-rs/core/src/rollout/tests.rs @@ -1,11 +1,14 @@ #![allow(clippy::unwrap_used, clippy::expect_used)] use std::fs::File; +use std::fs::FileTimes; use std::fs::{self}; use std::io::Write; use std::path::Path; +use pretty_assertions::assert_eq; use tempfile::TempDir; +use time::Duration; use time::OffsetDateTime; use time::PrimitiveDateTime; use time::format_description::FormatItem; @@ -15,6 +18,7 @@ use uuid::Uuid; use crate::rollout::INTERACTIVE_SESSION_SOURCES; use crate::rollout::list::Cursor; use crate::rollout::list::ThreadItem; +use crate::rollout::list::ThreadSortKey; use crate::rollout::list::ThreadsPage; use crate::rollout::list::get_threads; use anyhow::Result; @@ -83,10 +87,10 @@ fn write_session_file_with_provider( let mut payload = serde_json::json!({ "id": uuid, "timestamp": ts_str, - "instructions": null, "cwd": ".", "originator": "test_originator", "cli_version": "test_version", + "base_instructions": null, }); if let Some(source) = source { @@ -122,9 +126,53 @@ fn write_session_file_with_provider( }); writeln!(file, "{rec}")?; } + let times = FileTimes::new().set_modified(dt.into()); + file.set_times(times)?; Ok((dt, uuid)) } +fn write_session_file_with_meta_payload( + root: &Path, + ts_str: &str, + uuid: Uuid, + payload: serde_json::Value, +) -> std::io::Result<()> { + let format: &[FormatItem] = + format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]"); + let dt = PrimitiveDateTime::parse(ts_str, format) + .unwrap() + .assume_utc(); + let dir = root + .join("sessions") + .join(format!("{:04}", dt.year())) + .join(format!("{:02}", u8::from(dt.month()))) + .join(format!("{:02}", dt.day())); + fs::create_dir_all(&dir)?; + + let filename = format!("rollout-{ts_str}-{uuid}.jsonl"); + let file_path = dir.join(filename); + let mut file = File::create(file_path)?; + + let meta = serde_json::json!({ + "timestamp": ts_str, + "type": "session_meta", + "payload": payload, + }); + writeln!(file, "{meta}")?; + + let user_event = serde_json::json!({ + "timestamp": ts_str, + "type": "event_msg", + "payload": {"type": "user_message", "message": "Hello from user", "kind": "plain"} + }); + writeln!(file, "{user_event}")?; + + let times = FileTimes::new().set_modified(dt.into()); + file.set_times(times)?; + + Ok(()) +} + #[tokio::test] async fn test_list_conversations_latest_first() { let temp = TempDir::new().unwrap(); @@ -166,6 +214,7 @@ async fn test_list_conversations_latest_first() { home, 10, None, + ThreadSortKey::CreatedAt, INTERACTIVE_SESSION_SOURCES, Some(provider_filter.as_slice()), TEST_PROVIDER, @@ -196,32 +245,32 @@ async fn test_list_conversations_latest_first() { let head_3 = vec![serde_json::json!({ "id": u3, "timestamp": "2025-01-03T12-00-00", - "instructions": null, "cwd": ".", "originator": "test_originator", "cli_version": "test_version", "source": "vscode", "model_provider": "test-provider", + "base_instructions": null, })]; let head_2 = vec![serde_json::json!({ "id": u2, "timestamp": "2025-01-02T12-00-00", - "instructions": null, "cwd": ".", "originator": "test_originator", "cli_version": "test_version", "source": "vscode", "model_provider": "test-provider", + "base_instructions": null, })]; let head_1 = vec![serde_json::json!({ "id": u1, "timestamp": "2025-01-01T12-00-00", - "instructions": null, "cwd": ".", "originator": "test_originator", "cli_version": "test_version", "source": "vscode", "model_provider": "test-provider", + "base_instructions": null, })]; let updated_times: Vec> = @@ -315,6 +364,7 @@ async fn test_pagination_cursor() { home, 2, None, + ThreadSortKey::CreatedAt, INTERACTIVE_SESSION_SOURCES, Some(provider_filter.as_slice()), TEST_PROVIDER, @@ -336,22 +386,22 @@ async fn test_pagination_cursor() { let head_5 = vec![serde_json::json!({ "id": u5, "timestamp": "2025-03-05T09-00-00", - "instructions": null, "cwd": ".", "originator": "test_originator", "cli_version": "test_version", "source": "vscode", "model_provider": "test-provider", + "base_instructions": null, })]; let head_4 = vec![serde_json::json!({ "id": u4, "timestamp": "2025-03-04T09-00-00", - "instructions": null, "cwd": ".", "originator": "test_originator", "cli_version": "test_version", "source": "vscode", "model_provider": "test-provider", + "base_instructions": null, })]; let updated_page1: Vec> = page1.items.iter().map(|i| i.updated_at.clone()).collect(); @@ -382,6 +432,7 @@ async fn test_pagination_cursor() { home, 2, page1.next_cursor.as_ref(), + ThreadSortKey::CreatedAt, INTERACTIVE_SESSION_SOURCES, Some(provider_filter.as_slice()), TEST_PROVIDER, @@ -403,22 +454,22 @@ async fn test_pagination_cursor() { let head_3 = vec![serde_json::json!({ "id": u3, "timestamp": "2025-03-03T09-00-00", - "instructions": null, "cwd": ".", "originator": "test_originator", "cli_version": "test_version", "source": "vscode", "model_provider": "test-provider", + "base_instructions": null, })]; let head_2 = vec![serde_json::json!({ "id": u2, "timestamp": "2025-03-02T09-00-00", - "instructions": null, "cwd": ".", "originator": "test_originator", "cli_version": "test_version", "source": "vscode", "model_provider": "test-provider", + "base_instructions": null, })]; let updated_page2: Vec> = page2.items.iter().map(|i| i.updated_at.clone()).collect(); @@ -449,6 +500,7 @@ async fn test_pagination_cursor() { home, 2, page2.next_cursor.as_ref(), + ThreadSortKey::CreatedAt, INTERACTIVE_SESSION_SOURCES, Some(provider_filter.as_slice()), TEST_PROVIDER, @@ -464,12 +516,12 @@ async fn test_pagination_cursor() { let head_1 = vec![serde_json::json!({ "id": u1, "timestamp": "2025-03-01T09-00-00", - "instructions": null, "cwd": ".", "originator": "test_originator", "cli_version": "test_version", "source": "vscode", "model_provider": "test-provider", + "base_instructions": null, })]; let updated_page3: Vec> = page3.items.iter().map(|i| i.updated_at.clone()).collect(); @@ -501,6 +553,7 @@ async fn test_get_thread_contents() { home, 1, None, + ThreadSortKey::CreatedAt, INTERACTIVE_SESSION_SOURCES, Some(provider_filter.as_slice()), TEST_PROVIDER, @@ -521,12 +574,12 @@ async fn test_get_thread_contents() { let expected_head = vec![serde_json::json!({ "id": uuid, "timestamp": ts, - "instructions": null, "cwd": ".", "originator": "test_originator", "cli_version": "test_version", "source": "vscode", "model_provider": "test-provider", + "base_instructions": null, })]; let expected_page = ThreadsPage { items: vec![ThreadItem { @@ -548,10 +601,10 @@ async fn test_get_thread_contents() { "payload": { "id": uuid, "timestamp": ts, - "instructions": null, "cwd": ".", "originator": "test_originator", "cli_version": "test_version", + "base_instructions": null, "source": "vscode", "model_provider": "test-provider", } @@ -567,6 +620,139 @@ async fn test_get_thread_contents() { assert_eq!(content, expected_content); } +#[tokio::test] +async fn test_base_instructions_missing_in_meta_defaults_to_null() { + let temp = TempDir::new().unwrap(); + let home = temp.path(); + + let ts = "2025-04-02T10-30-00"; + let uuid = Uuid::from_u128(101); + let payload = serde_json::json!({ + "id": uuid, + "timestamp": ts, + "cwd": ".", + "originator": "test_originator", + "cli_version": "test_version", + "source": "vscode", + "model_provider": "test-provider", + }); + write_session_file_with_meta_payload(home, ts, uuid, payload).unwrap(); + + let provider_filter = provider_vec(&[TEST_PROVIDER]); + let page = get_threads( + home, + 1, + None, + ThreadSortKey::CreatedAt, + INTERACTIVE_SESSION_SOURCES, + Some(provider_filter.as_slice()), + TEST_PROVIDER, + ) + .await + .unwrap(); + + let head = page + .items + .first() + .and_then(|item| item.head.first()) + .expect("session meta head"); + assert_eq!( + head.get("base_instructions"), + Some(&serde_json::Value::Null) + ); +} + +#[tokio::test] +async fn test_base_instructions_present_in_meta_is_preserved() { + let temp = TempDir::new().unwrap(); + let home = temp.path(); + + let ts = "2025-04-03T10-30-00"; + let uuid = Uuid::from_u128(102); + let base_text = "Custom base instructions"; + let payload = serde_json::json!({ + "id": uuid, + "timestamp": ts, + "cwd": ".", + "originator": "test_originator", + "cli_version": "test_version", + "source": "vscode", + "model_provider": "test-provider", + "base_instructions": {"text": base_text}, + }); + write_session_file_with_meta_payload(home, ts, uuid, payload).unwrap(); + + let provider_filter = provider_vec(&[TEST_PROVIDER]); + let page = get_threads( + home, + 1, + None, + ThreadSortKey::CreatedAt, + INTERACTIVE_SESSION_SOURCES, + Some(provider_filter.as_slice()), + TEST_PROVIDER, + ) + .await + .unwrap(); + + let head = page + .items + .first() + .and_then(|item| item.head.first()) + .expect("session meta head"); + let base = head + .get("base_instructions") + .and_then(|value| value.get("text")) + .and_then(serde_json::Value::as_str); + assert_eq!(base, Some(base_text)); +} + +#[tokio::test] +async fn test_created_at_sort_uses_file_mtime_for_updated_at() -> Result<()> { + let temp = TempDir::new().unwrap(); + let home = temp.path(); + + let ts = "2025-06-01T08-00-00"; + let uuid = Uuid::from_u128(43); + write_session_file(home, ts, uuid, 0, Some(SessionSource::VSCode)).unwrap(); + + let created = PrimitiveDateTime::parse( + ts, + format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]"), + )? + .assume_utc(); + let updated = created + Duration::hours(2); + let expected_updated = updated.format(&time::format_description::well_known::Rfc3339)?; + + let file_path = home + .join("sessions") + .join("2025") + .join("06") + .join("01") + .join(format!("rollout-{ts}-{uuid}.jsonl")); + let file = std::fs::OpenOptions::new().write(true).open(&file_path)?; + let times = FileTimes::new().set_modified(updated.into()); + file.set_times(times)?; + + let provider_filter = provider_vec(&[TEST_PROVIDER]); + let page = get_threads( + home, + 1, + None, + ThreadSortKey::CreatedAt, + INTERACTIVE_SESSION_SOURCES, + Some(provider_filter.as_slice()), + TEST_PROVIDER, + ) + .await?; + + let item = page.items.first().expect("conversation item"); + assert_eq!(item.created_at.as_deref(), Some(ts)); + assert_eq!(item.updated_at.as_deref(), Some(expected_updated.as_str())); + + Ok(()) +} + #[tokio::test] async fn test_updated_at_uses_file_mtime() -> Result<()> { let temp = TempDir::new().unwrap(); @@ -585,13 +771,14 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> { item: RolloutItem::SessionMeta(SessionMetaLine { meta: SessionMeta { id: conversation_id, + forked_from_id: None, timestamp: ts.to_string(), - instructions: None, cwd: ".".into(), originator: "test_originator".into(), cli_version: "test_version".into(), source: SessionSource::VSCode, model_provider: Some("test-provider".into()), + base_instructions: None, }, git: None, }), @@ -630,6 +817,7 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> { home, 1, None, + ThreadSortKey::UpdatedAt, INTERACTIVE_SESSION_SOURCES, Some(provider_filter.as_slice()), TEST_PROVIDER, @@ -669,6 +857,7 @@ async fn test_stable_ordering_same_second_pagination() { home, 2, None, + ThreadSortKey::CreatedAt, INTERACTIVE_SESSION_SOURCES, Some(provider_filter.as_slice()), TEST_PROVIDER, @@ -692,12 +881,12 @@ async fn test_stable_ordering_same_second_pagination() { vec![serde_json::json!({ "id": u, "timestamp": ts, - "instructions": null, "cwd": ".", "originator": "test_originator", "cli_version": "test_version", "source": "vscode", "model_provider": "test-provider", + "base_instructions": null, })] }; let updated_page1: Vec> = @@ -728,6 +917,7 @@ async fn test_stable_ordering_same_second_pagination() { home, 2, page1.next_cursor.as_ref(), + ThreadSortKey::CreatedAt, INTERACTIVE_SESSION_SOURCES, Some(provider_filter.as_slice()), TEST_PROVIDER, @@ -786,6 +976,7 @@ async fn test_source_filter_excludes_non_matching_sessions() { home, 10, None, + ThreadSortKey::CreatedAt, INTERACTIVE_SESSION_SOURCES, Some(provider_filter.as_slice()), TEST_PROVIDER, @@ -803,9 +994,17 @@ async fn test_source_filter_excludes_non_matching_sessions() { path.ends_with("rollout-2025-08-02T10-00-00-00000000-0000-0000-0000-00000000002a.jsonl") })); - let all_sessions = get_threads(home, 10, None, NO_SOURCE_FILTER, None, TEST_PROVIDER) - .await - .unwrap(); + let all_sessions = get_threads( + home, + 10, + None, + ThreadSortKey::CreatedAt, + NO_SOURCE_FILTER, + None, + TEST_PROVIDER, + ) + .await + .unwrap(); let all_paths: Vec<_> = all_sessions .items .into_iter() @@ -861,6 +1060,7 @@ async fn test_model_provider_filter_selects_only_matching_sessions() -> Result<( home, 10, None, + ThreadSortKey::CreatedAt, NO_SOURCE_FILTER, Some(openai_filter.as_slice()), "openai", @@ -886,6 +1086,7 @@ async fn test_model_provider_filter_selects_only_matching_sessions() -> Result<( home, 10, None, + ThreadSortKey::CreatedAt, NO_SOURCE_FILTER, Some(beta_filter.as_slice()), "openai", @@ -906,6 +1107,7 @@ async fn test_model_provider_filter_selects_only_matching_sessions() -> Result<( home, 10, None, + ThreadSortKey::CreatedAt, NO_SOURCE_FILTER, Some(unknown_filter.as_slice()), "openai", @@ -913,7 +1115,16 @@ async fn test_model_provider_filter_selects_only_matching_sessions() -> Result<( .await?; assert!(unknown_sessions.items.is_empty()); - let all_sessions = get_threads(home, 10, None, NO_SOURCE_FILTER, None, "openai").await?; + let all_sessions = get_threads( + home, + 10, + None, + ThreadSortKey::CreatedAt, + NO_SOURCE_FILTER, + None, + "openai", + ) + .await?; assert_eq!(all_sessions.items.len(), 3); Ok(()) diff --git a/codex-rs/core/src/rollout/truncation.rs b/codex-rs/core/src/rollout/truncation.rs index 1f70be46f3..0f72cfc50e 100644 --- a/codex-rs/core/src/rollout/truncation.rs +++ b/codex-rs/core/src/rollout/truncation.rs @@ -189,7 +189,7 @@ mod tests { #[tokio::test] async fn ignores_session_prefix_messages_when_truncating_rollout_from_start() { let (session, turn_context) = make_session_and_context().await; - let mut items = session.build_initial_context(&turn_context); + let mut items = session.build_initial_context(&turn_context).await; items.push(user_msg("feature request")); items.push(assistant_msg("ack")); items.push(user_msg("second question")); diff --git a/codex-rs/core/src/session_prefix.rs b/codex-rs/core/src/session_prefix.rs new file mode 100644 index 0000000000..99283082b6 --- /dev/null +++ b/codex-rs/core/src/session_prefix.rs @@ -0,0 +1,15 @@ +/// Helpers for identifying model-visible "session prefix" messages. +/// +/// A session prefix is a user-role message that carries configuration or state needed by +/// follow-up turns (e.g. ``, ``). These items are persisted in +/// history so the model can see them, but they are not user intent and must not create user-turn +/// boundaries. +pub(crate) const ENVIRONMENT_CONTEXT_OPEN_TAG: &str = ""; +pub(crate) const TURN_ABORTED_OPEN_TAG: &str = ""; + +/// Returns true if `text` starts with a session prefix marker (case-insensitive). +pub(crate) fn is_session_prefix(text: &str) -> bool { + let trimmed = text.trim_start(); + let lowered = trimmed.to_ascii_lowercase(); + lowered.starts_with(ENVIRONMENT_CONTEXT_OPEN_TAG) || lowered.starts_with(TURN_ABORTED_OPEN_TAG) +} diff --git a/codex-rs/core/src/shell.rs b/codex-rs/core/src/shell.rs index d22b6543a9..b6adf861d8 100644 --- a/codex-rs/core/src/shell.rs +++ b/codex-rs/core/src/shell.rs @@ -340,6 +340,7 @@ mod detect_shell_type_tests { #[cfg(unix)] mod tests { use super::*; + use std::path::Path; use std::path::PathBuf; use std::process::Command; @@ -350,7 +351,7 @@ mod tests { let shell_path = zsh_shell.shell_path; - assert_eq!(shell_path, PathBuf::from("/bin/zsh")); + assert_eq!(shell_path, Path::new("/bin/zsh")); } #[test] @@ -360,7 +361,7 @@ mod tests { let shell_path = zsh_shell.shell_path; - assert_eq!(shell_path, PathBuf::from("/bin/zsh")); + assert_eq!(shell_path, Path::new("/bin/zsh")); } #[test] @@ -369,9 +370,9 @@ mod tests { let shell_path = bash_shell.shell_path; assert!( - shell_path == PathBuf::from("/bin/bash") - || shell_path == PathBuf::from("/usr/bin/bash") - || shell_path == PathBuf::from("/usr/local/bin/bash"), + shell_path == Path::new("/bin/bash") + || shell_path == Path::new("/usr/bin/bash") + || shell_path == Path::new("/usr/local/bin/bash"), "shell path: {shell_path:?}", ); } @@ -381,7 +382,7 @@ mod tests { let sh_shell = get_shell(ShellType::Sh, None).unwrap(); let shell_path = sh_shell.shell_path; assert!( - shell_path == PathBuf::from("/bin/sh") || shell_path == PathBuf::from("/usr/bin/sh"), + shell_path == Path::new("/bin/sh") || shell_path == Path::new("/usr/bin/sh"), "shell path: {shell_path:?}", ); } diff --git a/codex-rs/core/src/shell_snapshot.rs b/codex-rs/core/src/shell_snapshot.rs index 9c10eedf42..be316afa5b 100644 --- a/codex-rs/core/src/shell_snapshot.rs +++ b/codex-rs/core/src/shell_snapshot.rs @@ -135,6 +135,13 @@ async fn run_shell_script_with_timeout( // returns a ref of handler. let mut handler = Command::new(&args[0]); handler.args(&args[1..]); + #[cfg(unix)] + unsafe { + handler.pre_exec(|| { + codex_utils_pty::process_group::detach_from_tty()?; + Ok(()) + }); + } handler.kill_on_drop(true); let output = timeout(snapshot_timeout, handler.output()) .await diff --git a/codex-rs/core/src/skills/injection.rs b/codex-rs/core/src/skills/injection.rs index a143fce1f2..ffe879bcd9 100644 --- a/codex-rs/core/src/skills/injection.rs +++ b/codex-rs/core/src/skills/injection.rs @@ -1,8 +1,9 @@ use std::collections::HashSet; +use std::path::PathBuf; +use crate::instructions::SkillInstructions; use crate::skills::SkillLoadOutcome; use crate::skills::SkillMetadata; -use crate::user_instructions::SkillInstructions; use codex_protocol::models::ResponseItem; use codex_protocol::user_input::UserInput; use tokio::fs; @@ -25,7 +26,8 @@ pub(crate) async fn build_skill_injections( return SkillInjections::default(); }; - let mentioned_skills = collect_explicit_skill_mentions(inputs, &outcome.skills); + let mentioned_skills = + collect_explicit_skill_mentions(inputs, &outcome.skills, &outcome.disabled_paths); if mentioned_skills.is_empty() { return SkillInjections::default(); } @@ -46,9 +48,9 @@ pub(crate) async fn build_skill_injections( } Err(err) => { let message = format!( - "Failed to load skill {} at {}: {err:#}", - skill.name, - skill.path.display() + "Failed to load skill {name} at {path}: {err:#}", + name = skill.name, + path = skill.path.display() ); result.warnings.push(message); } @@ -61,6 +63,7 @@ pub(crate) async fn build_skill_injections( fn collect_explicit_skill_mentions( inputs: &[UserInput], skills: &[SkillMetadata], + disabled_paths: &HashSet, ) -> Vec { let mut selected: Vec = Vec::new(); let mut seen: HashSet = HashSet::new(); @@ -69,6 +72,7 @@ fn collect_explicit_skill_mentions( if let UserInput::Skill { name, path } = input && seen.insert(name.clone()) && let Some(skill) = skills.iter().find(|s| s.name == *name && s.path == *path) + && !disabled_paths.contains(&skill.path) { selected.push(skill.clone()); } diff --git a/codex-rs/core/src/skills/loader.rs b/codex-rs/core/src/skills/loader.rs index da51fea41e..1e02926572 100644 --- a/codex-rs/core/src/skills/loader.rs +++ b/codex-rs/core/src/skills/loader.rs @@ -105,13 +105,13 @@ where discover_skills_under_root(&root.path, root.scope, &mut outcome); } - let mut seen: HashSet = HashSet::new(); + let mut seen: HashSet = HashSet::new(); outcome .skills - .retain(|skill| seen.insert(skill.name.clone())); + .retain(|skill| seen.insert(skill.path.clone())); fn scope_rank(scope: SkillScope) -> u8 { - // Higher-priority scopes first (matches dedupe priority order). + // Higher-priority scopes first (matches root scan order for dedupe). match scope { SkillScope::Repo => 0, SkillScope::User => 1, @@ -445,10 +445,23 @@ fn resolve_asset_path( return None; } - let mut components = path.components().peekable(); - while matches!(components.peek(), Some(Component::CurDir)) { - components.next(); + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::Normal(component) => normalized.push(component), + Component::ParentDir => { + tracing::warn!("ignoring {field}: icon path must not contain '..'"); + return None; + } + _ => { + tracing::warn!("ignoring {field}: icon path must be under assets/"); + return None; + } + } } + + let mut components = normalized.components(); match components.next() { Some(Component::Normal(component)) if component == "assets" => {} _ => { @@ -456,12 +469,8 @@ fn resolve_asset_path( return None; } } - if components.any(|component| matches!(component, Component::ParentDir)) { - tracing::warn!("ignoring {field}: icon path must not contain '..'"); - return None; - } - Some(skill_dir.join(path)) + Some(skill_dir.join(normalized)) } fn sanitize_single_line(raw: &str) -> String { @@ -541,15 +550,20 @@ fn extract_frontmatter(contents: &str) -> Option { #[cfg(test)] mod tests { use super::*; + use crate::config::CONFIG_TOML_FILE; use crate::config::ConfigBuilder; use crate::config::ConfigOverrides; + use crate::config::ConfigToml; + use crate::config::ProjectConfig; use crate::config_loader::ConfigLayerEntry; use crate::config_loader::ConfigLayerStack; use crate::config_loader::ConfigRequirements; use crate::config_loader::ConfigRequirementsToml; + use codex_protocol::config_types::TrustLevel; use codex_protocol::protocol::SkillScope; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; + use std::collections::HashMap; use std::path::Path; use tempfile::TempDir; use toml::Value as TomlValue; @@ -561,6 +575,21 @@ mod tests { } async fn make_config_for_cwd(codex_home: &TempDir, cwd: PathBuf) -> Config { + fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + toml::to_string(&ConfigToml { + projects: Some(HashMap::from([( + cwd.to_string_lossy().to_string(), + ProjectConfig { + trust_level: Some(TrustLevel::Trusted), + }, + )])), + ..Default::default() + }) + .expect("serialize config"), + ) + .unwrap(); + let harness_overrides = ConfigOverrides { cwd: Some(cwd), ..Default::default() @@ -707,8 +736,8 @@ default_prompt = " default prompt " interface: Some(SkillInterface { display_name: Some("UI Skill".to_string()), short_description: Some("short desc".to_string()), - icon_small: Some(normalized_skill_dir.join("./assets/small-400px.png")), - icon_large: Some(normalized_skill_dir.join("./assets/large-logo.svg")), + icon_small: Some(normalized_skill_dir.join("assets/small-400px.png")), + icon_large: Some(normalized_skill_dir.join("assets/large-logo.svg")), brand_color: Some("#3B82F6".to_string()), default_prompt: Some("default prompt".to_string()), }), @@ -753,7 +782,7 @@ icon_large = "./assets/logo.svg" display_name: Some("UI Skill".to_string()), short_description: None, icon_small: Some(normalized_skill_dir.join("assets/icon.png")), - icon_large: Some(normalized_skill_dir.join("./assets/logo.svg")), + icon_large: Some(normalized_skill_dir.join("assets/logo.svg")), brand_color: None, default_prompt: None, }), @@ -835,7 +864,7 @@ default_prompt = "{too_long}" interface: Some(SkillInterface { display_name: Some("UI Skill".to_string()), short_description: None, - icon_small: Some(normalized_skill_dir.join("./assets/small-400px.png")), + icon_small: Some(normalized_skill_dir.join("assets/small-400px.png")), icon_large: None, brand_color: None, default_prompt: None, @@ -1376,12 +1405,47 @@ icon_large = "./assets/../logo.svg" } #[tokio::test] - async fn deduplicates_by_name_preferring_repo_over_user() { + async fn deduplicates_by_path_preferring_first_root() { + let root = tempfile::tempdir().expect("tempdir"); + + let skill_path = write_skill_at(root.path(), "dupe", "dupe-skill", "from repo"); + + let outcome = load_skills_from_roots([ + SkillRoot { + path: root.path().to_path_buf(), + scope: SkillScope::Repo, + }, + SkillRoot { + path: root.path().to_path_buf(), + scope: SkillScope::User, + }, + ]); + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "dupe-skill".to_string(), + description: "from repo".to_string(), + short_description: None, + interface: None, + path: normalized(&skill_path), + scope: SkillScope::Repo, + }] + ); + } + + #[tokio::test] + async fn keeps_duplicate_names_from_repo_and_user() { let codex_home = tempfile::tempdir().expect("tempdir"); let repo_dir = tempfile::tempdir().expect("tempdir"); mark_as_git_repo(repo_dir.path()); - let _user_skill_path = write_skill(&codex_home, "user", "dupe-skill", "from user"); + let user_skill_path = write_skill(&codex_home, "user", "dupe-skill", "from user"); let repo_skill_path = write_skill_at( &repo_dir .path() @@ -1402,42 +1466,94 @@ icon_large = "./assets/../logo.svg" ); assert_eq!( outcome.skills, - vec![SkillMetadata { - name: "dupe-skill".to_string(), - description: "from repo".to_string(), - short_description: None, - interface: None, - path: normalized(&repo_skill_path), - scope: SkillScope::Repo, - }] + vec![ + SkillMetadata { + name: "dupe-skill".to_string(), + description: "from repo".to_string(), + short_description: None, + interface: None, + path: normalized(&repo_skill_path), + scope: SkillScope::Repo, + }, + SkillMetadata { + name: "dupe-skill".to_string(), + description: "from user".to_string(), + short_description: None, + interface: None, + path: normalized(&user_skill_path), + scope: SkillScope::User, + }, + ] ); } #[tokio::test] - async fn loads_system_skills_when_present() { + async fn keeps_duplicate_names_from_nested_codex_dirs() { let codex_home = tempfile::tempdir().expect("tempdir"); + let repo_dir = tempfile::tempdir().expect("tempdir"); + mark_as_git_repo(repo_dir.path()); - let _system_skill_path = - write_system_skill(&codex_home, "system", "dupe-skill", "from system"); - let user_skill_path = write_skill(&codex_home, "user", "dupe-skill", "from user"); + let nested_dir = repo_dir.path().join("nested/inner"); + fs::create_dir_all(&nested_dir).unwrap(); - let cfg = make_config(&codex_home).await; + let root_skill_path = write_skill_at( + &repo_dir + .path() + .join(REPO_ROOT_CONFIG_DIR_NAME) + .join(SKILLS_DIR_NAME), + "root", + "dupe-skill", + "from root", + ); + let nested_skill_path = write_skill_at( + &repo_dir + .path() + .join("nested") + .join(REPO_ROOT_CONFIG_DIR_NAME) + .join(SKILLS_DIR_NAME), + "nested", + "dupe-skill", + "from nested", + ); + + let cfg = make_config_for_cwd(&codex_home, nested_dir).await; let outcome = load_skills(&cfg); + assert!( outcome.errors.is_empty(), "unexpected errors: {:?}", outcome.errors ); + let root_path = + canonicalize_path(&root_skill_path).unwrap_or_else(|_| root_skill_path.clone()); + let nested_path = + canonicalize_path(&nested_skill_path).unwrap_or_else(|_| nested_skill_path.clone()); + let (first_path, second_path, first_description, second_description) = + if root_path <= nested_path { + (root_path, nested_path, "from root", "from nested") + } else { + (nested_path, root_path, "from nested", "from root") + }; assert_eq!( outcome.skills, - vec![SkillMetadata { - name: "dupe-skill".to_string(), - description: "from user".to_string(), - short_description: None, - interface: None, - path: normalized(&user_skill_path), - scope: SkillScope::User, - }] + vec![ + SkillMetadata { + name: "dupe-skill".to_string(), + description: first_description.to_string(), + short_description: None, + interface: None, + path: first_path, + scope: SkillScope::Repo, + }, + SkillMetadata { + name: "dupe-skill".to_string(), + description: second_description.to_string(), + short_description: None, + interface: None, + path: second_path, + scope: SkillScope::Repo, + }, + ] ); } @@ -1448,7 +1564,7 @@ icon_large = "./assets/../logo.svg" let repo_dir = outer_dir.path().join("repo"); fs::create_dir_all(&repo_dir).unwrap(); - write_skill_at( + let _skill_path = write_skill_at( &outer_dir .path() .join(REPO_ROOT_CONFIG_DIR_NAME) @@ -1457,7 +1573,6 @@ icon_large = "./assets/../logo.svg" "outer-skill", "from outer", ); - mark_as_git_repo(&repo_dir); let cfg = make_config_for_cwd(&codex_home, repo_dir).await; @@ -1581,164 +1696,4 @@ icon_large = "./assets/../logo.svg" } assert_eq!(scopes, expected); } - - #[tokio::test] - async fn deduplicates_by_name_preferring_system_over_admin() { - let system_dir = tempfile::tempdir().expect("tempdir"); - let admin_dir = tempfile::tempdir().expect("tempdir"); - - let system_skill_path = - write_skill_at(system_dir.path(), "system", "dupe-skill", "from system"); - let _admin_skill_path = - write_skill_at(admin_dir.path(), "admin", "dupe-skill", "from admin"); - - let outcome = load_skills_from_roots([ - SkillRoot { - path: system_dir.path().to_path_buf(), - scope: SkillScope::System, - }, - SkillRoot { - path: admin_dir.path().to_path_buf(), - scope: SkillScope::Admin, - }, - ]); - - assert!( - outcome.errors.is_empty(), - "unexpected errors: {:?}", - outcome.errors - ); - assert_eq!( - outcome.skills, - vec![SkillMetadata { - name: "dupe-skill".to_string(), - description: "from system".to_string(), - short_description: None, - interface: None, - path: normalized(&system_skill_path), - scope: SkillScope::System, - }] - ); - } - - #[tokio::test] - async fn deduplicates_by_name_preferring_user_over_system() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let work_dir = tempfile::tempdir().expect("tempdir"); - - let user_skill_path = write_skill(&codex_home, "user", "dupe-skill", "from user"); - let _system_skill_path = - write_system_skill(&codex_home, "system", "dupe-skill", "from system"); - - let cfg = make_config_for_cwd(&codex_home, work_dir.path().to_path_buf()).await; - - let outcome = load_skills(&cfg); - assert!( - outcome.errors.is_empty(), - "unexpected errors: {:?}", - outcome.errors - ); - assert_eq!( - outcome.skills, - vec![SkillMetadata { - name: "dupe-skill".to_string(), - description: "from user".to_string(), - short_description: None, - interface: None, - path: normalized(&user_skill_path), - scope: SkillScope::User, - }] - ); - } - - #[tokio::test] - async fn deduplicates_by_name_preferring_repo_over_system() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let repo_dir = tempfile::tempdir().expect("tempdir"); - mark_as_git_repo(repo_dir.path()); - - let repo_skill_path = write_skill_at( - &repo_dir - .path() - .join(REPO_ROOT_CONFIG_DIR_NAME) - .join(SKILLS_DIR_NAME), - "repo", - "dupe-skill", - "from repo", - ); - let _system_skill_path = - write_system_skill(&codex_home, "system", "dupe-skill", "from system"); - - let cfg = make_config_for_cwd(&codex_home, repo_dir.path().to_path_buf()).await; - - let outcome = load_skills(&cfg); - assert!( - outcome.errors.is_empty(), - "unexpected errors: {:?}", - outcome.errors - ); - assert_eq!( - outcome.skills, - vec![SkillMetadata { - name: "dupe-skill".to_string(), - description: "from repo".to_string(), - short_description: None, - interface: None, - path: normalized(&repo_skill_path), - scope: SkillScope::Repo, - }] - ); - } - - #[tokio::test] - async fn deduplicates_by_name_preferring_nearest_project_codex_dir() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let repo_dir = tempfile::tempdir().expect("tempdir"); - mark_as_git_repo(repo_dir.path()); - - let nested_dir = repo_dir.path().join("nested/inner"); - fs::create_dir_all(&nested_dir).unwrap(); - - let _root_skill_path = write_skill_at( - &repo_dir - .path() - .join(REPO_ROOT_CONFIG_DIR_NAME) - .join(SKILLS_DIR_NAME), - "root", - "dupe-skill", - "from root", - ); - let nested_skill_path = write_skill_at( - &repo_dir - .path() - .join("nested") - .join(REPO_ROOT_CONFIG_DIR_NAME) - .join(SKILLS_DIR_NAME), - "nested", - "dupe-skill", - "from nested", - ); - - let cfg = make_config_for_cwd(&codex_home, nested_dir).await; - let outcome = load_skills(&cfg); - - assert!( - outcome.errors.is_empty(), - "unexpected errors: {:?}", - outcome.errors - ); - let expected_path = - canonicalize_path(&nested_skill_path).unwrap_or_else(|_| nested_skill_path.clone()); - assert_eq!( - vec![SkillMetadata { - name: "dupe-skill".to_string(), - description: "from nested".to_string(), - short_description: None, - interface: None, - path: expected_path, - scope: SkillScope::Repo, - }], - outcome.skills - ); - } } diff --git a/codex-rs/core/src/skills/manager.rs b/codex-rs/core/src/skills/manager.rs index bff928a565..8aa6986882 100644 --- a/codex-rs/core/src/skills/manager.rs +++ b/codex-rs/core/src/skills/manager.rs @@ -1,12 +1,15 @@ use std::collections::HashMap; +use std::collections::HashSet; use std::path::Path; use std::path::PathBuf; use std::sync::RwLock; use codex_utils_absolute_path::AbsolutePathBuf; use toml::Value as TomlValue; +use tracing::warn; use crate::config::Config; +use crate::config::types::SkillsConfig; use crate::config_loader::LoaderOverrides; use crate::config_loader::load_config_layers_state; use crate::skills::SkillLoadOutcome; @@ -44,7 +47,8 @@ impl SkillsManager { } let roots = skill_roots_from_layer_stack(&config.config_layer_stack); - let outcome = load_skills_from_roots(roots); + let mut outcome = load_skills_from_roots(roots); + outcome.disabled_paths = disabled_paths_from_stack(&config.config_layer_stack); match self.cache_by_cwd.write() { Ok(mut cache) => { cache.insert(cwd.to_path_buf(), outcome.clone()); @@ -100,7 +104,8 @@ impl SkillsManager { }; let roots = skill_roots_from_layer_stack(&config_layer_stack); - let outcome = load_skills_from_roots(roots); + let mut outcome = load_skills_from_roots(roots); + outcome.disabled_paths = disabled_paths_from_stack(&config_layer_stack); match self.cache_by_cwd.write() { Ok(mut cache) => { cache.insert(cwd.to_path_buf(), outcome.clone()); @@ -111,6 +116,51 @@ impl SkillsManager { } outcome } + + pub fn clear_cache(&self) { + match self.cache_by_cwd.write() { + Ok(mut cache) => cache.clear(), + Err(err) => err.into_inner().clear(), + } + } +} + +fn disabled_paths_from_stack( + config_layer_stack: &crate::config_loader::ConfigLayerStack, +) -> HashSet { + let mut disabled = HashSet::new(); + let mut configs = HashMap::new(); + // Skills config is user-layer only for now; higher-precedence layers are ignored. + let Some(user_layer) = config_layer_stack.get_user_layer() else { + return disabled; + }; + let Some(skills_value) = user_layer.config.get("skills") else { + return disabled; + }; + let skills: SkillsConfig = match skills_value.clone().try_into() { + Ok(skills) => skills, + Err(err) => { + warn!("invalid skills config: {err}"); + return disabled; + } + }; + + for entry in skills.config { + let path = normalize_override_path(entry.path.as_path()); + configs.insert(path, entry.enabled); + } + + for (path, enabled) in configs { + if !enabled { + disabled.insert(path); + } + } + + disabled +} + +fn normalize_override_path(path: &Path) -> PathBuf { + dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) } #[cfg(test)] diff --git a/codex-rs/core/src/skills/model.rs b/codex-rs/core/src/skills/model.rs index fba00d2711..fe3357f9d9 100644 --- a/codex-rs/core/src/skills/model.rs +++ b/codex-rs/core/src/skills/model.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use std::path::PathBuf; use codex_protocol::protocol::SkillScope; @@ -32,4 +33,25 @@ pub struct SkillError { pub struct SkillLoadOutcome { pub skills: Vec, pub errors: Vec, + pub disabled_paths: HashSet, +} + +impl SkillLoadOutcome { + pub fn is_skill_enabled(&self, skill: &SkillMetadata) -> bool { + !self.disabled_paths.contains(&skill.path) + } + + pub fn enabled_skills(&self) -> Vec { + self.skills + .iter() + .filter(|skill| self.is_skill_enabled(skill)) + .cloned() + .collect() + } + + pub fn skills_with_enabled(&self) -> impl Iterator { + self.skills + .iter() + .map(|skill| (skill, self.is_skill_enabled(skill))) + } } diff --git a/codex-rs/core/src/spawn.rs b/codex-rs/core/src/spawn.rs index c1a8d4457a..b2a507fda7 100644 --- a/codex-rs/core/src/spawn.rs +++ b/codex-rs/core/src/spawn.rs @@ -66,12 +66,12 @@ pub(crate) async fn spawn_child_async( #[cfg(unix)] unsafe { - let set_process_group = matches!(stdio_policy, StdioPolicy::RedirectForShellTool); + let detach_from_tty = matches!(stdio_policy, StdioPolicy::RedirectForShellTool); #[cfg(target_os = "linux")] let parent_pid = libc::getpid(); cmd.pre_exec(move || { - if set_process_group { - codex_utils_pty::process_group::set_process_group()?; + if detach_from_tty { + codex_utils_pty::process_group::detach_from_tty()?; } // This relies on prctl(2), so it only works on Linux. diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index c61d188373..746396949e 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -14,6 +14,7 @@ pub(crate) struct SessionState { pub(crate) session_configuration: SessionConfiguration, pub(crate) history: ContextManager, pub(crate) latest_rate_limits: Option, + pub(crate) server_reasoning_included: bool, } impl SessionState { @@ -24,6 +25,7 @@ impl SessionState { session_configuration, history, latest_rate_limits: None, + server_reasoning_included: false, } } @@ -78,8 +80,17 @@ impl SessionState { self.history.set_token_usage_full(context_window); } - pub(crate) fn get_total_token_usage(&self) -> i64 { - self.history.get_total_token_usage() + pub(crate) fn get_total_token_usage(&self, server_reasoning_included: bool) -> i64 { + self.history + .get_total_token_usage(server_reasoning_included) + } + + pub(crate) fn set_server_reasoning_included(&mut self, included: bool) { + self.server_reasoning_included = included; + } + + pub(crate) fn server_reasoning_included(&self) -> bool { + self.server_reasoning_included } } diff --git a/codex-rs/core/src/state/turn.rs b/codex-rs/core/src/state/turn.rs index ddc9efafea..66e2f694e9 100644 --- a/codex-rs/core/src/state/turn.rs +++ b/codex-rs/core/src/state/turn.rs @@ -9,6 +9,7 @@ use tokio_util::sync::CancellationToken; use tokio_util::task::AbortOnDropHandle; use codex_protocol::models::ResponseInputItem; +use codex_protocol::request_user_input::RequestUserInputResponse; use tokio::sync::oneshot; use crate::codex::TurnContext; @@ -37,7 +38,6 @@ pub(crate) enum TaskKind { Compact, } -#[derive(Clone)] pub(crate) struct RunningTask { pub(crate) done: Arc, pub(crate) kind: TaskKind, @@ -45,6 +45,8 @@ pub(crate) struct RunningTask { pub(crate) cancellation_token: CancellationToken, pub(crate) handle: Arc>, pub(crate) turn_context: Arc, + // Timer recorded when the task drops to capture the full turn duration. + pub(crate) _timer: Option, } impl ActiveTurn { @@ -67,6 +69,7 @@ impl ActiveTurn { #[derive(Default)] pub(crate) struct TurnState { pending_approvals: HashMap>, + pending_user_input: HashMap>, pending_input: Vec, } @@ -88,9 +91,25 @@ impl TurnState { pub(crate) fn clear_pending(&mut self) { self.pending_approvals.clear(); + self.pending_user_input.clear(); self.pending_input.clear(); } + pub(crate) fn insert_pending_user_input( + &mut self, + key: String, + tx: oneshot::Sender, + ) -> Option> { + self.pending_user_input.insert(key, tx) + } + + pub(crate) fn remove_pending_user_input( + &mut self, + key: &str, + ) -> Option> { + self.pending_user_input.remove(key) + } + pub(crate) fn push_pending_input(&mut self, input: ResponseInputItem) { self.pending_input.push(input); } diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index d6754c23c8..91303a3bdc 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -24,9 +24,13 @@ use crate::protocol::EventMsg; use crate::protocol::TurnAbortReason; use crate::protocol::TurnAbortedEvent; use crate::protocol::TurnCompleteEvent; +use crate::session_prefix::TURN_ABORTED_OPEN_TAG; use crate::state::ActiveTurn; use crate::state::RunningTask; use crate::state::TaskKind; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::RolloutItem; use codex_protocol::user_input::UserInput; pub(crate) use compact::CompactTask; @@ -37,6 +41,7 @@ pub(crate) use undo::UndoTask; pub(crate) use user_shell::UserShellCommandTask; const GRACEFULL_INTERRUPTION_TIMEOUT_MS: u64 = 100; +const TURN_ABORTED_INTERRUPTED_GUIDANCE: &str = "The user interrupted the previous turn. Do not continue or repeat work from that turn unless the user explicitly asks. If any tools/commands were aborted, they may have partially executed; verify current state before retrying."; /// Thin wrapper that exposes the parts of [`Session`] task runners need. #[derive(Clone)] @@ -144,6 +149,12 @@ impl Session { }) }; + let timer = turn_context + .client + .get_otel_manager() + .start_timer("codex.turn.e2e_duration_ms", &[]) + .ok(); + let running_task = RunningTask { done, handle: Arc::new(AbortOnDropHandle::new(handle)), @@ -151,6 +162,7 @@ impl Session { task, cancellation_token, turn_context: Arc::clone(&turn_context), + _timer: timer, }; self.register_new_active_task(running_task).await; } @@ -235,6 +247,25 @@ impl Session { .abort(session_ctx, Arc::clone(&task.turn_context)) .await; + if reason == TurnAbortReason::Interrupted { + let marker = ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: format!( + "{TURN_ABORTED_OPEN_TAG}\n {sub_id}\n interrupted\n {TURN_ABORTED_INTERRUPTED_GUIDANCE}\n" + ), + }], + }; + self.record_into_history(std::slice::from_ref(&marker), task.turn_context.as_ref()) + .await; + self.persist_rollout_items(&[RolloutItem::ResponseItem(marker)]) + .await; + // Ensure the marker is durably visible before emitting TurnAborted: some clients + // synchronously re-read the rollout on receipt of the abort event. + self.flush_rollout().await; + } + let event = EventMsg::TurnAborted(TurnAbortedEvent { reason }); self.send_event(task.turn_context.as_ref(), event).await; } diff --git a/codex-rs/core/src/tasks/regular.rs b/codex-rs/core/src/tasks/regular.rs index f897d3ce87..cac0cd5da0 100644 --- a/codex-rs/core/src/tasks/regular.rs +++ b/codex-rs/core/src/tasks/regular.rs @@ -30,6 +30,7 @@ impl SessionTask for RegularTask { ) -> Option { let sess = session.clone_session(); let run_turn_span = trace_span!("run_turn"); + sess.set_server_reasoning_included(false).await; sess.services .otel_manager .apply_traceparent_parent(&run_turn_span); diff --git a/codex-rs/core/src/tasks/review.rs b/codex-rs/core/src/tasks/review.rs index 9157e922ec..5b0d0bebe9 100644 --- a/codex-rs/core/src/tasks/review.rs +++ b/codex-rs/core/src/tasks/review.rs @@ -190,8 +190,8 @@ pub(crate) async fn exit_review_mode( review_output: Option, ctx: Arc, ) { - const REVIEW_USER_MESSAGE_ID: &str = "review:rollout:user"; - const REVIEW_ASSISTANT_MESSAGE_ID: &str = "review:rollout:assistant"; + const REVIEW_USER_MESSAGE_ID: &str = "review_rollout_user"; + const REVIEW_ASSISTANT_MESSAGE_ID: &str = "review_rollout_assistant"; let (user_message, assistant_message) = if let Some(out) = review_output.clone() { let mut findings_str = String::new(); let text = out.overall_explanation.trim(); diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 2b7ae53d0c..508b16d484 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -27,6 +27,7 @@ use crate::sandboxing::ExecEnv; use crate::sandboxing::SandboxPermissions; use crate::state::TaskKind; use crate::tools::format_exec_output_str; +use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot; use crate::user_shell_command::user_shell_command_record_item; use super::SessionTask; @@ -74,9 +75,9 @@ impl SessionTask for UserShellCommandTask { // allows commands that use shell features (pipes, &&, redirects, etc.). // We do not source rc files or otherwise reformat the script. let use_login_shell = true; - let command = session - .user_shell() - .derive_exec_args(&self.command, use_login_shell); + let session_shell = session.user_shell(); + let command = session_shell.derive_exec_args(&self.command, use_login_shell); + let command = maybe_wrap_shell_lc_with_snapshot(&command, session_shell.as_ref()); let call_id = Uuid::new_v4().to_string(); let raw_command = self.command.clone(); diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 6f2187f527..01dc99d91a 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -19,6 +19,7 @@ use crate::rollout::RolloutRecorder; use crate::rollout::truncation; use crate::skills::SkillsManager; use codex_protocol::ThreadId; +use codex_protocol::config_types::CollaborationMode; use codex_protocol::openai_models::ModelPreset; use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::McpServerRefreshConfig; @@ -157,6 +158,10 @@ impl ThreadManager { .await } + pub fn list_collaboration_modes(&self) -> Vec { + self.state.models_manager.list_collaboration_modes() + } + pub async fn list_thread_ids(&self) -> Vec { self.state.threads.read().await.keys().copied().collect() } @@ -230,6 +235,15 @@ impl ThreadManager { self.state.threads.write().await.remove(thread_id) } + /// Closes all threads open in this ThreadManager + pub async fn remove_and_close_all_threads(&self) -> CodexResult<()> { + for thread in self.state.threads.read().await.values() { + thread.submit(Op::Shutdown).await?; + } + self.state.threads.write().await.clear(); + Ok(()) + } + /// Fork an existing thread by taking messages up to the given position (not including /// the message at the given position) and starting a new thread with identical /// configuration (unless overridden by the caller's `config`). The new thread will have @@ -462,7 +476,7 @@ mod tests { #[tokio::test] async fn ignores_session_prefix_messages_when_truncating() { let (session, turn_context) = make_session_and_context().await; - let mut items = session.build_initial_context(&turn_context); + let mut items = session.build_initial_context(&turn_context).await; items.push(user_msg("feature request")); items.push(assistant_msg("ack")); items.push(user_msg("second question")); diff --git a/codex-rs/core/src/tools/handlers/collab.rs b/codex-rs/core/src/tools/handlers/collab.rs index df3f2901da..56463e644a 100644 --- a/codex-rs/core/src/tools/handlers/collab.rs +++ b/codex-rs/core/src/tools/handlers/collab.rs @@ -12,6 +12,7 @@ use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; use async_trait::async_trait; use codex_protocol::ThreadId; +use codex_protocol::models::BaseInstructions; use codex_protocol::protocol::CollabAgentInteractionBeginEvent; use codex_protocol::protocol::CollabAgentInteractionEndEvent; use codex_protocol::protocol::CollabAgentSpawnBeginEvent; @@ -115,10 +116,12 @@ mod spawn { .into(), ) .await; - let mut config = build_agent_spawn_config(turn.as_ref())?; + let mut config = + build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?; agent_role .apply_to_config(&mut config) .map_err(FunctionCallError::RespondToModel)?; + let result = session .services .agent_control @@ -254,20 +257,26 @@ mod send_input { mod wait { use super::*; use crate::agent::status::is_final; + use futures::FutureExt; + use futures::StreamExt; + use futures::stream::FuturesUnordered; + use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; + use tokio::sync::watch::Receiver; use tokio::time::Instant; + use tokio::time::timeout_at; #[derive(Debug, Deserialize)] struct WaitArgs { - id: String, + ids: Vec, timeout_ms: Option, } #[derive(Debug, Serialize)] struct WaitResult { - status: AgentStatus, + status: HashMap, timed_out: bool, } @@ -278,7 +287,16 @@ mod wait { arguments: String, ) -> Result { let args: WaitArgs = parse_arguments(&arguments)?; - let receiver_thread_id = agent_id(&args.id)?; + if args.ids.is_empty() { + return Err(FunctionCallError::RespondToModel( + "ids must be non-empty".to_owned(), + )); + } + let receiver_thread_ids = args + .ids + .iter() + .map(|id| agent_id(id)) + .collect::, _>>()?; // Validate timeout. let timeout_ms = args.timeout_ms.unwrap_or(DEFAULT_WAIT_TIMEOUT_MS); @@ -296,105 +314,131 @@ mod wait { &turn, CollabWaitingBeginEvent { sender_thread_id: session.conversation_id, - receiver_thread_id, + receiver_thread_ids: receiver_thread_ids.clone(), call_id: call_id.clone(), } .into(), ) .await; - let status_rx = match session - .services - .agent_control - .subscribe_status(receiver_thread_id) - .await - { - Ok(status_rx) => status_rx, - Err(err) => { - let status = session - .services - .agent_control - .get_status(receiver_thread_id) - .await; - session - .send_event( - &turn, - CollabWaitingEndEvent { - sender_thread_id: session.conversation_id, - receiver_thread_id, - call_id: call_id.clone(), - status, - } - .into(), - ) - .await; - return Err(collab_agent_error(receiver_thread_id, err)); + + let mut status_rxs = Vec::with_capacity(receiver_thread_ids.len()); + let mut initial_final_statuses = Vec::new(); + for id in &receiver_thread_ids { + match session.services.agent_control.subscribe_status(*id).await { + Ok(rx) => { + let status = rx.borrow().clone(); + if is_final(&status) { + initial_final_statuses.push((*id, status)); + } + status_rxs.push((*id, rx)); + } + Err(CodexErr::ThreadNotFound(_)) => { + initial_final_statuses.push((*id, AgentStatus::NotFound)); + } + Err(err) => { + let mut statuses = HashMap::with_capacity(1); + statuses.insert(*id, session.services.agent_control.get_status(*id).await); + session + .send_event( + &turn, + CollabWaitingEndEvent { + sender_thread_id: session.conversation_id, + call_id: call_id.clone(), + statuses, + } + .into(), + ) + .await; + return Err(collab_agent_error(*id, err)); + } } + } + + let statuses = if !initial_final_statuses.is_empty() { + initial_final_statuses + } else { + // Wait for the first agent to reach a final status. + let mut futures = FuturesUnordered::new(); + for (id, rx) in status_rxs.into_iter() { + let session = session.clone(); + futures.push(wait_for_final_status(session, id, rx)); + } + let mut results = Vec::new(); + let deadline = Instant::now() + Duration::from_millis(timeout_ms as u64); + loop { + match timeout_at(deadline, futures.next()).await { + Ok(Some(Some(result))) => { + results.push(result); + break; + } + Ok(Some(None)) => continue, + Ok(None) | Err(_) => break, + } + } + if !results.is_empty() { + // Drain the unlikely last elements to prevent race. + loop { + match futures.next().now_or_never() { + Some(Some(Some(result))) => results.push(result), + Some(Some(None)) => continue, + Some(None) | None => break, + } + } + } + results }; - let result = - wait_for_status(session.as_ref(), receiver_thread_id, timeout_ms, status_rx).await; + + // Convert payload. + let statuses_map = statuses.clone().into_iter().collect::>(); + let result = WaitResult { + status: statuses_map.clone(), + timed_out: statuses.is_empty(), + }; + + // Final event emission. session .send_event( &turn, CollabWaitingEndEvent { sender_thread_id: session.conversation_id, - receiver_thread_id, call_id, - status: result.status.clone(), + statuses: statuses_map, } .into(), ) .await; - if matches!(result.status, AgentStatus::NotFound) { - return Err(FunctionCallError::RespondToModel(format!( - "agent with id {receiver_thread_id} not found" - ))); - } - let content = serde_json::to_string(&result).map_err(|err| { 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(success), + success: None, content_items: None, }) } - async fn wait_for_status( - session: &Session, - agent_id: ThreadId, - timeout_ms: i64, - mut status_rx: tokio::sync::watch::Receiver, - ) -> WaitResult { - // Get last known status. - let mut status = status_rx.borrow_and_update().clone(); - let deadline = Instant::now() + Duration::from_millis(timeout_ms as u64); + async fn wait_for_final_status( + session: Arc, + thread_id: ThreadId, + mut status_rx: Receiver, + ) -> Option<(ThreadId, AgentStatus)> { + let mut status = status_rx.borrow().clone(); + if is_final(&status) { + return Some((thread_id, status)); + } - let timed_out = loop { + loop { + if status_rx.changed().await.is_err() { + let latest = session.services.agent_control.get_status(thread_id).await; + return is_final(&latest).then_some((thread_id, latest)); + } + status = status_rx.borrow().clone(); if is_final(&status) { - break false; + return Some((thread_id, status)); } - - match timeout_at(deadline, status_rx.changed()).await { - Ok(Ok(())) => status = status_rx.borrow().clone(), - Ok(Err(_)) => { - let last_status = session.services.agent_control.get_status(agent_id).await; - if last_status != AgentStatus::NotFound { - // On-purpose we keep the last known status if the agent gets dropped. This - // event is not supposed to happen. - status = last_status; - } - break false; - } - Err(_) => break true, - } - }; - - WaitResult { status, timed_out } + } } } @@ -516,15 +560,18 @@ fn collab_agent_error(agent_id: ThreadId, err: CodexErr) -> FunctionCallError { } } -fn build_agent_spawn_config(turn: &TurnContext) -> Result { +fn build_agent_spawn_config( + base_instructions: &BaseInstructions, + turn: &TurnContext, +) -> Result { let base_config = turn.client.config(); let mut config = (*base_config).clone(); + config.base_instructions = Some(base_instructions.text.clone()); config.model = Some(turn.client.get_model()); config.model_provider = turn.client.get_provider(); config.model_reasoning_effort = turn.client.get_reasoning_effort(); config.model_reasoning_summary = turn.client.get_reasoning_summary(); config.developer_instructions = turn.developer_instructions.clone(); - config.base_instructions = turn.base_instructions.clone(); config.compact_prompt = turn.compact_prompt.clone(); config.user_instructions = turn.user_instructions.clone(); config.shell_environment_policy = turn.shell_environment_policy.clone(); @@ -560,7 +607,9 @@ mod tests { use crate::turn_diff_tracker::TurnDiffTracker; use codex_protocol::ThreadId; use pretty_assertions::assert_eq; + use serde::Deserialize; use serde_json::json; + use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -772,6 +821,12 @@ mod tests { .expect("shutdown should submit"); } + #[derive(Debug, Deserialize, PartialEq, Eq)] + struct WaitResult { + status: HashMap, + timed_out: bool, + } + #[tokio::test] async fn wait_rejects_non_positive_timeout() { let (session, turn) = make_session_and_context().await; @@ -779,7 +834,10 @@ mod tests { Arc::new(session), Arc::new(turn), "wait", - function_payload(json!({"id": ThreadId::new().to_string(), "timeout_ms": 0})), + function_payload(json!({ + "ids": [ThreadId::new().to_string()], + "timeout_ms": 0 + })), ); let Err(err) = CollabHandler.handle(invocation).await else { panic!("non-positive timeout should be rejected"); @@ -797,7 +855,7 @@ mod tests { Arc::new(session), Arc::new(turn), "wait", - function_payload(json!({"id": "invalid"})), + function_payload(json!({"ids": ["invalid"]})), ); let Err(err) = CollabHandler.handle(invocation).await else { panic!("invalid id should be rejected"); @@ -808,6 +866,65 @@ mod tests { assert!(msg.starts_with("invalid agent id invalid:")); } + #[tokio::test] + async fn wait_rejects_empty_ids() { + let (session, turn) = make_session_and_context().await; + let invocation = invocation( + Arc::new(session), + Arc::new(turn), + "wait", + function_payload(json!({"ids": []})), + ); + let Err(err) = CollabHandler.handle(invocation).await else { + panic!("empty ids should be rejected"); + }; + assert_eq!( + err, + FunctionCallError::RespondToModel("ids must be non-empty".to_string()) + ); + } + + #[tokio::test] + async fn wait_returns_not_found_for_missing_agents() { + let (mut session, turn) = make_session_and_context().await; + let manager = thread_manager(); + session.services.agent_control = manager.agent_control(); + let id_a = ThreadId::new(); + let id_b = ThreadId::new(); + let invocation = invocation( + Arc::new(session), + Arc::new(turn), + "wait", + function_payload(json!({ + "ids": [id_a.to_string(), id_b.to_string()], + "timeout_ms": 1000 + })), + ); + let output = CollabHandler + .handle(invocation) + .await + .expect("wait should succeed"); + let ToolOutput::Function { + content, success, .. + } = output + else { + panic!("expected function output"); + }; + let result: WaitResult = + serde_json::from_str(&content).expect("wait result should be json"); + assert_eq!( + result, + WaitResult { + status: HashMap::from([ + (id_a, AgentStatus::NotFound), + (id_b, AgentStatus::NotFound), + ]), + timed_out: false + } + ); + assert_eq!(success, None); + } + #[tokio::test] async fn wait_times_out_when_status_is_not_final() { let (mut session, turn) = make_session_and_context().await; @@ -820,7 +937,10 @@ mod tests { Arc::new(session), Arc::new(turn), "wait", - function_payload(json!({"id": agent_id.to_string(), "timeout_ms": 10})), + function_payload(json!({ + "ids": [agent_id.to_string()], + "timeout_ms": 10 + })), ); let output = CollabHandler .handle(invocation) @@ -832,8 +952,16 @@ mod tests { else { panic!("expected function output"); }; - assert_eq!(content, r#"{"status":"pending_init","timed_out":true}"#); - assert_eq!(success, Some(false)); + let result: WaitResult = + serde_json::from_str(&content).expect("wait result should be json"); + assert_eq!( + result, + WaitResult { + status: HashMap::new(), + timed_out: true + } + ); + assert_eq!(success, None); let _ = thread .thread @@ -869,7 +997,10 @@ mod tests { Arc::new(session), Arc::new(turn), "wait", - function_payload(json!({"id": agent_id.to_string(), "timeout_ms": 1000})), + function_payload(json!({ + "ids": [agent_id.to_string()], + "timeout_ms": 1000 + })), ); let output = CollabHandler .handle(invocation) @@ -881,8 +1012,16 @@ mod tests { else { panic!("expected function output"); }; - assert_eq!(content, r#"{"status":"shutdown","timed_out":false}"#); - assert_eq!(success, Some(true)); + let result: WaitResult = + serde_json::from_str(&content).expect("wait result should be json"); + assert_eq!( + result, + WaitResult { + status: HashMap::from([(agent_id, AgentStatus::Shutdown)]), + timed_out: false + } + ); + assert_eq!(success, None); } #[tokio::test] @@ -929,8 +1068,10 @@ mod tests { #[tokio::test] async fn build_agent_spawn_config_uses_turn_context_values() { let (_session, mut turn) = make_session_and_context().await; + let base_instructions = BaseInstructions { + text: "base".to_string(), + }; turn.developer_instructions = Some("dev".to_string()); - turn.base_instructions = Some("base".to_string()); turn.compact_prompt = Some("compact".to_string()); turn.user_instructions = Some("user".to_string()); turn.shell_environment_policy = ShellEnvironmentPolicy { @@ -943,14 +1084,14 @@ mod tests { turn.approval_policy = AskForApproval::Never; turn.sandbox_policy = SandboxPolicy::DangerFullAccess; - let config = build_agent_spawn_config(&turn).expect("spawn config"); + let config = build_agent_spawn_config(&base_instructions, &turn).expect("spawn config"); let mut expected = (*turn.client.config()).clone(); + expected.base_instructions = Some(base_instructions.text); expected.model = Some(turn.client.get_model()); expected.model_provider = turn.client.get_provider(); expected.model_reasoning_effort = turn.client.get_reasoning_effort(); expected.model_reasoning_summary = turn.client.get_reasoning_summary(); expected.developer_instructions = turn.developer_instructions.clone(); - expected.base_instructions = turn.base_instructions.clone(); expected.compact_prompt = turn.compact_prompt.clone(); expected.user_instructions = turn.user_instructions.clone(); expected.shell_environment_policy = turn.shell_environment_policy.clone(); diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index ab8123df1e..8b63c9567f 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -6,6 +6,7 @@ mod mcp; mod mcp_resource; mod plan; mod read_file; +mod request_user_input; mod shell; mod test_sync; mod unified_exec; @@ -23,6 +24,7 @@ pub use mcp::McpHandler; pub use mcp_resource::McpResourceHandler; pub use plan::PlanHandler; pub use read_file::ReadFileHandler; +pub use request_user_input::RequestUserInputHandler; pub use shell::ShellCommandHandler; pub use shell::ShellHandler; pub use test_sync::TestSyncHandler; diff --git a/codex-rs/core/src/tools/handlers/read_file.rs b/codex-rs/core/src/tools/handlers/read_file.rs index 4f187540a6..eb5ce647ce 100644 --- a/codex-rs/core/src/tools/handlers/read_file.rs +++ b/codex-rs/core/src/tools/handlers/read_file.rs @@ -40,9 +40,10 @@ struct ReadFileArgs { indentation: Option, } -#[derive(Deserialize)] +#[derive(Deserialize, Default)] #[serde(rename_all = "snake_case")] enum ReadMode { + #[default] Slice, Indentation, } @@ -461,12 +462,6 @@ mod defaults { } } - impl Default for ReadMode { - fn default() -> Self { - Self::Slice - } - } - pub fn offset() -> usize { 1 } diff --git a/codex-rs/core/src/tools/handlers/request_user_input.rs b/codex-rs/core/src/tools/handlers/request_user_input.rs new file mode 100644 index 0000000000..f63b11d235 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/request_user_input.rs @@ -0,0 +1,60 @@ +use async_trait::async_trait; + +use crate::function_tool::FunctionCallError; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolOutput; +use crate::tools::context::ToolPayload; +use crate::tools::handlers::parse_arguments; +use crate::tools::registry::ToolHandler; +use crate::tools::registry::ToolKind; +use codex_protocol::request_user_input::RequestUserInputArgs; + +pub struct RequestUserInputHandler; + +#[async_trait] +impl ToolHandler for RequestUserInputHandler { + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + call_id, + payload, + .. + } = invocation; + + let arguments = match payload { + ToolPayload::Function { arguments } => arguments, + _ => { + return Err(FunctionCallError::RespondToModel( + "request_user_input handler received unsupported payload".to_string(), + )); + } + }; + + let args: RequestUserInputArgs = parse_arguments(&arguments)?; + let response = session + .request_user_input(turn.as_ref(), call_id, args) + .await + .ok_or_else(|| { + FunctionCallError::RespondToModel( + "request_user_input was cancelled before receiving a response".to_string(), + ) + })?; + + let content = serde_json::to_string(&response).map_err(|err| { + FunctionCallError::Fatal(format!( + "failed to serialize request_user_input response: {err}" + )) + })?; + + Ok(ToolOutput::Function { + content, + content_items: None, + success: Some(true), + }) + } +} diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index d90ed3b644..daa39bc358 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -202,7 +202,7 @@ impl ToolHandler for UnifiedExecHandler { }) .await .map_err(|err| { - FunctionCallError::RespondToModel(format!("write_stdin failed: {err:?}")) + FunctionCallError::RespondToModel(format!("write_stdin failed: {err}")) })?; let interaction = TerminalInteractionEvent { diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 5a35f2b648..e330d93a03 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -27,6 +27,7 @@ pub(crate) struct ToolsConfig { pub apply_patch_tool_type: Option, pub web_search_mode: Option, pub collab_tools: bool, + pub collaboration_modes_tools: bool, pub experimental_supported_tools: Vec, } @@ -45,6 +46,7 @@ impl ToolsConfig { } = params; let include_apply_patch_tool = features.enabled(Feature::ApplyPatchFreeform); let include_collab_tools = features.enabled(Feature::Collab); + let include_collaboration_modes_tools = features.enabled(Feature::CollaborationModes); let shell_type = if !features.enabled(Feature::ShellTool) { ConfigShellToolType::Disabled @@ -76,6 +78,7 @@ impl ToolsConfig { apply_patch_tool_type, web_search_mode: *web_search_mode, collab_tools: include_collab_tools, + collaboration_modes_tools: include_collaboration_modes_tools, experimental_supported_tools: model_info.experimental_supported_tools.clone(), } } @@ -503,9 +506,10 @@ fn create_send_input_tool() -> ToolSpec { fn create_wait_tool() -> ToolSpec { let mut properties = BTreeMap::new(); properties.insert( - "id".to_string(), - JsonSchema::String { - description: Some("Identifier of the agent to wait on.".to_string()), + "ids".to_string(), + JsonSchema::Array { + items: Box::new(JsonSchema::String { description: None }), + description: Some("Identifiers of the agents to wait on.".to_string()), }, ); properties.insert( @@ -519,11 +523,95 @@ fn create_wait_tool() -> ToolSpec { ToolSpec::Function(ResponsesApiTool { name: "wait".to_string(), - description: "Wait for an agent and return its status.".to_string(), + description: + "Wait for agents and return their statuses. If no agent is done, no status get returned." + .to_string(), strict: false, parameters: JsonSchema::Object { properties, - required: Some(vec!["id".to_string()]), + required: Some(vec!["ids".to_string()]), + additional_properties: Some(false.into()), + }, + }) +} + +fn create_request_user_input_tool() -> ToolSpec { + let mut option_props = BTreeMap::new(); + option_props.insert( + "label".to_string(), + JsonSchema::String { + description: Some("User-facing label (1-5 words).".to_string()), + }, + ); + option_props.insert( + "description".to_string(), + JsonSchema::String { + description: Some( + "One short sentence explaining impact/tradeoff if selected.".to_string(), + ), + }, + ); + + let options_schema = JsonSchema::Array { + description: Some( + "Optional 2-3 mutually exclusive choices. Put the recommended option first and suffix its label with \"(Recommended)\". Only include \"Other\" option if we want to include a free form option. If the question is free form in nature, please do not have any option." + .to_string(), + ), + items: Box::new(JsonSchema::Object { + properties: option_props, + required: Some(vec!["label".to_string(), "description".to_string()]), + additional_properties: Some(false.into()), + }), + }; + + let mut question_props = BTreeMap::new(); + question_props.insert( + "id".to_string(), + JsonSchema::String { + description: Some("Stable identifier for mapping answers (snake_case).".to_string()), + }, + ); + question_props.insert( + "header".to_string(), + JsonSchema::String { + description: Some( + "Short header label shown in the UI (12 or fewer chars).".to_string(), + ), + }, + ); + question_props.insert( + "question".to_string(), + JsonSchema::String { + description: Some("Single-sentence prompt shown to the user.".to_string()), + }, + ); + question_props.insert("options".to_string(), options_schema); + + let questions_schema = JsonSchema::Array { + description: Some("Questions to show the user. Prefer 1 and do not exceed 3".to_string()), + items: Box::new(JsonSchema::Object { + properties: question_props, + required: Some(vec![ + "id".to_string(), + "header".to_string(), + "question".to_string(), + ]), + additional_properties: Some(false.into()), + }), + }; + + let mut properties = BTreeMap::new(); + properties.insert("questions".to_string(), questions_schema); + + ToolSpec::Function(ResponsesApiTool { + name: "request_user_input".to_string(), + description: + "Request user input for one to three short questions and wait for the response." + .to_string(), + strict: false, + parameters: JsonSchema::Object { + properties, + required: Some(vec!["questions".to_string()]), additional_properties: Some(false.into()), }, }) @@ -1137,6 +1225,7 @@ pub(crate) fn build_specs( use crate::tools::handlers::McpResourceHandler; use crate::tools::handlers::PlanHandler; use crate::tools::handlers::ReadFileHandler; + use crate::tools::handlers::RequestUserInputHandler; use crate::tools::handlers::ShellCommandHandler; use crate::tools::handlers::ShellHandler; use crate::tools::handlers::TestSyncHandler; @@ -1154,6 +1243,7 @@ pub(crate) fn build_specs( let mcp_handler = Arc::new(McpHandler); let mcp_resource_handler = Arc::new(McpResourceHandler); let shell_command_handler = Arc::new(ShellCommandHandler); + let request_user_input_handler = Arc::new(RequestUserInputHandler); match &config.shell_type { ConfigShellToolType::Default => { @@ -1194,6 +1284,11 @@ pub(crate) fn build_specs( builder.push_spec(PLAN_TOOL.clone()); builder.register_handler("update_plan", plan_handler); + if config.collaboration_modes_tools { + builder.push_spec(create_request_user_input_tool()); + builder.register_handler("request_user_input", request_user_input_handler); + } + if let Some(apply_patch_tool_type) = &config.apply_patch_tool_type { match apply_patch_tool_type { ApplyPatchToolType::Freeform => { @@ -1395,6 +1490,7 @@ mod tests { let model_info = ModelsManager::construct_model_info_offline("gpt-5-codex", &config); let mut features = Features::with_defaults(); features.enable(Feature::UnifiedExec); + features.enable(Feature::CollaborationModes); let config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &features, @@ -1427,6 +1523,7 @@ mod tests { create_list_mcp_resource_templates_tool(), create_read_mcp_resource_tool(), PLAN_TOOL.clone(), + create_request_user_input_tool(), create_apply_patch_freeform_tool(), ToolSpec::WebSearch { external_web_access: Some(true), @@ -1457,6 +1554,7 @@ mod tests { let model_info = ModelsManager::construct_model_info_offline("gpt-5-codex", &config); let mut features = Features::with_defaults(); features.enable(Feature::Collab); + features.enable(Feature::CollaborationModes); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &features, @@ -1469,6 +1567,33 @@ mod tests { ); } + #[test] + fn request_user_input_requires_collaboration_modes_feature() { + let config = test_config(); + let model_info = ModelsManager::construct_model_info_offline("gpt-5-codex", &config); + let mut features = Features::with_defaults(); + features.disable(Feature::CollaborationModes); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + }); + let (tools, _) = build_specs(&tools_config, None).build(); + assert!( + !tools.iter().any(|t| t.spec.name() == "request_user_input"), + "request_user_input should be disabled when collaboration_modes feature is off" + ); + + features.enable(Feature::CollaborationModes); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + }); + let (tools, _) = build_specs(&tools_config, None).build(); + assert_contains_tool_names(&tools, &["request_user_input"]); + } + fn assert_model_tools( model_slug: &str, features: &Features, @@ -1533,9 +1658,11 @@ mod tests { #[test] fn test_build_specs_gpt5_codex_default() { + let mut features = Features::with_defaults(); + features.enable(Feature::CollaborationModes); assert_model_tools( "gpt-5-codex", - &Features::with_defaults(), + &features, Some(WebSearchMode::Cached), &[ "shell_command", @@ -1543,6 +1670,7 @@ mod tests { "list_mcp_resource_templates", "read_mcp_resource", "update_plan", + "request_user_input", "apply_patch", "web_search", "view_image", @@ -1552,9 +1680,11 @@ mod tests { #[test] fn test_build_specs_gpt51_codex_default() { + let mut features = Features::with_defaults(); + features.enable(Feature::CollaborationModes); assert_model_tools( "gpt-5.1-codex", - &Features::with_defaults(), + &features, Some(WebSearchMode::Cached), &[ "shell_command", @@ -1562,6 +1692,7 @@ mod tests { "list_mcp_resource_templates", "read_mcp_resource", "update_plan", + "request_user_input", "apply_patch", "web_search", "view_image", @@ -1571,9 +1702,12 @@ mod tests { #[test] fn test_build_specs_gpt5_codex_unified_exec_web_search() { + let mut features = Features::with_defaults(); + features.enable(Feature::UnifiedExec); + features.enable(Feature::CollaborationModes); assert_model_tools( "gpt-5-codex", - Features::with_defaults().enable(Feature::UnifiedExec), + &features, Some(WebSearchMode::Live), &[ "exec_command", @@ -1582,6 +1716,7 @@ mod tests { "list_mcp_resource_templates", "read_mcp_resource", "update_plan", + "request_user_input", "apply_patch", "web_search", "view_image", @@ -1591,9 +1726,12 @@ mod tests { #[test] fn test_build_specs_gpt51_codex_unified_exec_web_search() { + let mut features = Features::with_defaults(); + features.enable(Feature::UnifiedExec); + features.enable(Feature::CollaborationModes); assert_model_tools( "gpt-5.1-codex", - Features::with_defaults().enable(Feature::UnifiedExec), + &features, Some(WebSearchMode::Live), &[ "exec_command", @@ -1602,6 +1740,7 @@ mod tests { "list_mcp_resource_templates", "read_mcp_resource", "update_plan", + "request_user_input", "apply_patch", "web_search", "view_image", @@ -1611,9 +1750,11 @@ mod tests { #[test] fn test_codex_mini_defaults() { + let mut features = Features::with_defaults(); + features.enable(Feature::CollaborationModes); assert_model_tools( "codex-mini-latest", - &Features::with_defaults(), + &features, Some(WebSearchMode::Cached), &[ "local_shell", @@ -1621,6 +1762,7 @@ mod tests { "list_mcp_resource_templates", "read_mcp_resource", "update_plan", + "request_user_input", "web_search", "view_image", ], @@ -1629,9 +1771,11 @@ mod tests { #[test] fn test_codex_5_1_mini_defaults() { + let mut features = Features::with_defaults(); + features.enable(Feature::CollaborationModes); assert_model_tools( "gpt-5.1-codex-mini", - &Features::with_defaults(), + &features, Some(WebSearchMode::Cached), &[ "shell_command", @@ -1639,6 +1783,7 @@ mod tests { "list_mcp_resource_templates", "read_mcp_resource", "update_plan", + "request_user_input", "apply_patch", "web_search", "view_image", @@ -1648,9 +1793,11 @@ mod tests { #[test] fn test_gpt_5_defaults() { + let mut features = Features::with_defaults(); + features.enable(Feature::CollaborationModes); assert_model_tools( "gpt-5", - &Features::with_defaults(), + &features, Some(WebSearchMode::Cached), &[ "shell", @@ -1658,6 +1805,7 @@ mod tests { "list_mcp_resource_templates", "read_mcp_resource", "update_plan", + "request_user_input", "web_search", "view_image", ], @@ -1666,9 +1814,11 @@ mod tests { #[test] fn test_gpt_5_1_defaults() { + let mut features = Features::with_defaults(); + features.enable(Feature::CollaborationModes); assert_model_tools( "gpt-5.1", - &Features::with_defaults(), + &features, Some(WebSearchMode::Cached), &[ "shell_command", @@ -1676,6 +1826,7 @@ mod tests { "list_mcp_resource_templates", "read_mcp_resource", "update_plan", + "request_user_input", "apply_patch", "web_search", "view_image", @@ -1685,9 +1836,11 @@ mod tests { #[test] fn test_exp_5_1_defaults() { + let mut features = Features::with_defaults(); + features.enable(Feature::CollaborationModes); assert_model_tools( "exp-5.1", - &Features::with_defaults(), + &features, Some(WebSearchMode::Cached), &[ "exec_command", @@ -1696,6 +1849,7 @@ mod tests { "list_mcp_resource_templates", "read_mcp_resource", "update_plan", + "request_user_input", "apply_patch", "web_search", "view_image", @@ -1705,9 +1859,12 @@ mod tests { #[test] fn test_codex_mini_unified_exec_web_search() { + let mut features = Features::with_defaults(); + features.enable(Feature::UnifiedExec); + features.enable(Feature::CollaborationModes); assert_model_tools( "codex-mini-latest", - Features::with_defaults().enable(Feature::UnifiedExec), + &features, Some(WebSearchMode::Live), &[ "exec_command", @@ -1716,6 +1873,7 @@ mod tests { "list_mcp_resource_templates", "read_mcp_resource", "update_plan", + "request_user_input", "web_search", "view_image", ], diff --git a/codex-rs/core/src/unified_exec/errors.rs b/codex-rs/core/src/unified_exec/errors.rs index d8df389252..284c7bca6d 100644 --- a/codex-rs/core/src/unified_exec/errors.rs +++ b/codex-rs/core/src/unified_exec/errors.rs @@ -10,6 +10,10 @@ pub(crate) enum UnifiedExecError { UnknownProcessId { process_id: String }, #[error("failed to write to stdin")] WriteToStdin, + #[error( + "stdin is closed for this session; rerun exec_command with tty=true to keep stdin open" + )] + StdinClosed, #[error("missing command line for unified exec request")] MissingCommandLine, #[error("Command denied by sandbox: {message}")] diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index 1db38b9905..3f359c3866 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -136,6 +136,7 @@ struct ProcessEntry { call_id: String, process_id: String, command: Vec, + tty: bool, last_used: tokio::time::Instant, } @@ -353,6 +354,8 @@ mod tests { async fn unified_exec_timeouts() -> anyhow::Result<()> { skip_if_sandbox!(Ok(())); + const TEST_VAR_VALUE: &str = "unified_exec_var_123"; + let (session, turn) = test_session_and_turn().await; let open_shell = exec_command(&session, &turn, "bash -i", 2_500).await?; @@ -365,7 +368,7 @@ mod tests { write_stdin( &session, process_id, - "export CODEX_INTERACTIVE_SHELL_VAR=codex\n", + format!("export CODEX_INTERACTIVE_SHELL_VAR={TEST_VAR_VALUE}\n").as_str(), 2_500, ) .await?; @@ -378,7 +381,7 @@ mod tests { ) .await?; assert!( - !out_2.output.contains("codex"), + !out_2.output.contains(TEST_VAR_VALUE), "timeout too short should yield incomplete output" ); @@ -387,7 +390,7 @@ mod tests { let out_3 = write_stdin(&session, process_id, "", 100).await?; assert!( - out_3.output.contains("codex"), + out_3.output.contains(TEST_VAR_VALUE), "subsequent poll should retrieve output" ); diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 851b92d24f..3230e75b15 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -47,7 +47,7 @@ use crate::unified_exec::process::OutputHandles; use crate::unified_exec::process::UnifiedExecProcess; use crate::unified_exec::resolve_max_tokens; -const UNIFIED_EXEC_ENV: [(&str, &str); 9] = [ +const UNIFIED_EXEC_ENV: [(&str, &str); 10] = [ ("NO_COLOR", "1"), ("TERM", "dumb"), ("LANG", "C.UTF-8"), @@ -57,6 +57,7 @@ const UNIFIED_EXEC_ENV: [(&str, &str); 9] = [ ("PAGER", "cat"), ("GIT_PAGER", "cat"), ("GH_PAGER", "cat"), + ("CODEX_CI", "1"), ]; fn apply_unified_exec_env(mut env: HashMap) -> HashMap { @@ -73,6 +74,7 @@ struct PreparedProcessHandles { cancellation_token: CancellationToken, command: Vec, process_id: String, + tty: bool, } impl UnifiedExecProcessManager { @@ -217,6 +219,7 @@ impl UnifiedExecProcessManager { cwd.clone(), start, process_id, + request.tty, Arc::clone(&transcript), ) .await; @@ -255,10 +258,14 @@ impl UnifiedExecProcessManager { cancellation_token, command: session_command, process_id, + tty, .. } = self.prepare_process_handles(process_id.as_str()).await?; if !request.input.is_empty() { + if !tty { + return Err(UnifiedExecError::StdinClosed); + } Self::send_input(&writer_tx, request.input.as_bytes()).await?; // Give the remote process a brief window to react so that we are // more likely to capture its output in the poll below. @@ -379,6 +386,7 @@ impl UnifiedExecProcessManager { cancellation_token, command: entry.command.clone(), process_id: entry.process_id.clone(), + tty: entry.tty, }) } @@ -401,6 +409,7 @@ impl UnifiedExecProcessManager { cwd: PathBuf, started_at: Instant, process_id: String, + tty: bool, transcript: Arc>, ) { let entry = ProcessEntry { @@ -408,6 +417,7 @@ impl UnifiedExecProcessManager { call_id: context.call_id.clone(), process_id: process_id.clone(), command: command.to_vec(), + tty, last_used: started_at, }; let number_processes = { @@ -460,7 +470,7 @@ impl UnifiedExecProcessManager { ) .await } else { - codex_utils_pty::pipe::spawn_process( + codex_utils_pty::pipe::spawn_process_no_stdin( program, args, env.cwd.as_path(), @@ -688,6 +698,7 @@ mod tests { ("PAGER".to_string(), "cat".to_string()), ("GIT_PAGER".to_string(), "cat".to_string()), ("GH_PAGER".to_string(), "cat".to_string()), + ("CODEX_CI".to_string(), "1".to_string()), ]); assert_eq!(env, expected); diff --git a/codex-rs/core/templates/agents/orchestrator.md b/codex-rs/core/templates/agents/orchestrator.md index 89d98c9334..09b6ea0b4e 100644 --- a/codex-rs/core/templates/agents/orchestrator.md +++ b/codex-rs/core/templates/agents/orchestrator.md @@ -1,38 +1,73 @@ You are Codex Orchestrator, based on GPT-5. You are running as an orchestration agent in the Codex CLI on a user's computer. ## Role -- The interface between the user and the workers. Your role is to understand a problem and then delegate/coordinate workers to solve the task. -- Monitor progress, resolve conflicts, and integrate results into a single, coherent outcome. -- You can perform basic actions such as code exploration or running basic commands if needed to understand the problem, but you must delegate the hard work to workers. -- If a task can be split in well scoped sub-tasks, use multiple workers to solve it, and you take care of the global orchestration. -- Your job is not finished before the entire task is completed. While this is not the case, keep monitoring and coordinating your workers. -- Do not rush the workers. If they are working, let them work and don't ask them to "finalize now" unless requested by the user. + +* You are the interface between the user and the workers. +* Your job is to understand the task, decompose it, and delegate well-scoped work to workers. +* You coordinate execution, monitor progress, resolve conflicts, and integrate results into a single coherent outcome. +* You may perform lightweight actions (e.g. reading files, basic commands) to understand the task, but all substantive work must be delegated to workers. +* **Your job is not finished until the entire task is fully completed and verified.** +* While the task is incomplete, you must keep monitoring and coordinating workers. You must not return early. + +## Core invariants + +* **Never stop monitoring workers.** +* **Do not rush workers. Be patient.** +* The orchestrator must not return unless the task is fully accomplished. +* If the user ask you a question/status while you are working, always answer him before continuing your work. + +## Worker execution semantics + +* While a worker is running, you cannot observe intermediate state. +* Workers are able to run commands, update/create/delete files etc. They can be considered as fully autonomous agents +* Messages sent with `send_input` are queued and processed only after the worker finishes, unless interrupted. +* Therefore: + * Do not send messages to “check status” or “ask for progress” unless being asked. + * Monitoring happens exclusively via `wait`. + * Sending a message is a commitment for the *next* phase of work. + +## Interrupt semantics + +* If a worker is taking longer than expected but is still working, do nothing and keep waiting unless being asked. +* Only intervene if you must change, stop, or redirect the *current* work. +* To stop a worker’s current task, you **must** use `send_input(interrupt=true)`. +* Use `interrupt=true` sparingly and deliberately. ## Multi-agent workflow -1. Understand the request and identify the optimal set of workers needed. +1. Understand the request and determine the optimal set of workers. If the task can be divided into sub-tasks, spawn one worker per sub-task and make them work together. 2. Spawn worker(s) with precise goals, constraints, and expected deliverables. -3. Monitor workers with `wait`, route questions via `send_input`, and keep scope boundaries clear. -4. When all workers report done, verify their work to make sure the task was correctly solved. -5. If you spot issues, assign fixes to the relevant worker(s) and repeat steps 3–5 until the task is correctly completed. -6. Close all agents when you don't need them anymore (i.e. when the task if fully finished). +3. Monitor workers using `wait`. +4. When a worker finishes: + * verify correctness, + * check integration with other work, + * assess whether the global task is closer to completion. +5. If issues remain, assign fixes to the appropriate worker(s) and repeat steps 3–5. Do not fix yourself unless the fixes are very small. +6. Close agents only when no further work is required from them. +7. Return to the user only when the task is fully completed and verified. ## Collaboration rules -- Tell every worker they are not alone in the environment and must not revert or overwrite others' work. -- Default: workers must not spawn sub-agents unless you explicitly allow it. -- When multiple workers are running, you can provide multiple ids to `wait` in order to wait for the first worker to finish. This will make your workflow event-based as the tool will return when the first agent is done (i.e. when you need to react on it). +* Workers operate in a shared environment. You must tell it to them. +* Workers must not revert, overwrite, or conflict with others’ work. +* By default, workers must not spawn sub-agents unless explicitly allowed. +* When multiple workers are active, you may pass multiple IDs to `wait` to react to the first completion and keep the workflow event-driven and use a long timeout (e.g. 5 minutes). ## Collab tools -- `spawn_agent`: create a worker with an initial prompt (set `agent_type`). -- `send_input`: send follow-ups, clarifications, or fix requests (`interrupt` can stop the current task first). -- `wait`: poll the completion status of a list of workers. Return once at least one worker in the list is done. -- `close_agent`: close the agent when done. +* `spawn_agent`: create a worker with an initial prompt (`agent_type` required). +* `send_input`: send follow-ups or fixes (queued unless interrupted). +* `send_input(interrupt=true)`: stop current work and redirect immediately. +* `wait`: wait for one or more workers; returns when at least one finishes. +* `close_agent`: close a worker when fully done. -## Presenting your work and final message +## Final response -- Keep responses concise, factual, and in plain text. -- Summarize: what was delegated, key outcomes, tests/verification status, and any remaining risks. -- If verification failed, state the issues clearly and what you asked workers to change. -- Do not dump large files; reference paths with backticks. +* Keep responses concise, factual, and in plain text. +* Summarize: + * what was delegated, + * key outcomes, + * verification performed, + * and any remaining risks. +* If verification failed, state issues clearly and describe what was reassigned. +* Do not dump large files inline; reference paths using backticks. diff --git a/codex-rs/core/templates/collaboration_mode/execute.md b/codex-rs/core/templates/collaboration_mode/execute.md new file mode 100644 index 0000000000..c57878242d --- /dev/null +++ b/codex-rs/core/templates/collaboration_mode/execute.md @@ -0,0 +1,45 @@ +# Collaboration Style: Execute +You execute on a well-specified task independently and report progress. + +You do not collaborate on decisions in this mode. You execute end-to-end. +You make reasonable assumptions when the user hasn't specified something, and you proceed without asking questions. + +## Assumptions-first execution +When information is missing, do not ask the user questions. +Instead: +- Make a sensible assumption. +- Clearly state the assumption in the final message (briefly). +- Continue executing. + +Group assumptions logically, for example architecture/frameworks/implementation, features/behavior, design/themes/feel. +If the user does not react to a proposed suggestion, consider it accepted. + +## Execution principles +*Think out loud.* Share reasoning when it helps the user evaluate tradeoffs. Keep explanations short and grounded in consequences. Avoid design lectures or exhaustive option lists. + +*Use reasonable assumptions.* When the user hasn't specified something, suggest a sensible choice instead of asking an open-ended question. Group your assumptions logically, for example architecture/frameworks/implementation, features/behavior, design/themes/feel. Clearly label suggestions as provisional. Share reasoning when it helps the user evaluate tradeoffs. Keep explanations short and grounded in consequences. They should be easy to accept or override. If the user does not react to a proposed suggestion, consider it accepted. + +Example: "There are a few viable ways to structure this. A plugin model gives flexibility but adds complexity; a simpler core with extension points is easier to reason about. Given what you've said about your team's size, I'd lean towards the latter." +Example: "If this is a shared internal library, I'll assume API stability matters more than rapid iteration." + +*Think ahead.* What else might the user need? How will the user test and understand what you did? Think about ways to support them and propose things they might need BEFORE you build. Offer at least one suggestion you came up with by thinking ahead. +Example: "This feature changes as time passes but you probably want to test it without waiting for a full hour to pass. I'll include a debug mode where you can move through states without just waiting." + +*Be mindful of time.* The user is right here with you. Any time you spend reading files or searching for information is time that the user is waiting for you. Do make use of these tools if helpful, but minimize the time the user is waiting for you. As a rule of thumb, spend only a few seconds on most turns and no more than 60 seconds when doing research. If you are missing information and would normally ask, make a reasonable assumption and continue. +Example: "I checked the readme and searched for the feature you mentioned, but didn't find it immediately. I'll proceed with the most likely implementation and verify behavior with a quick test." + +## Long-horizon execution +Treat the task as a sequence of concrete steps that add up to a complete delivery. +- Break the work into milestones that move the task forward in a visible way. +- Execute step by step, verifying along the way rather than doing everything at the end. +- If the task is large, keep a running checklist of what is done, what is next, and what is blocked. +- Avoid blocking on uncertainty: choose a reasonable default and continue. + +## Reporting progress +In this phase you show progress on your task and appraise the user of your progress using plan tool. +- Provide updates that directly map to the work you are doing (what changed, what you verified, what remains). +- If something fails, report what failed, what you tried, and what you will do next. +- When you finish, summarize what you delivered and how the user can validate it. + +## Executing +Once you start working, you should execute independently. Your job is to deliver the task and report progress. diff --git a/codex-rs/core/templates/collaboration_mode/pair_programming.md b/codex-rs/core/templates/collaboration_mode/pair_programming.md new file mode 100644 index 0000000000..1297129b1d --- /dev/null +++ b/codex-rs/core/templates/collaboration_mode/pair_programming.md @@ -0,0 +1,7 @@ +# Collaboration Style: Pair Programming + +## Build together as you go +You treat collaboration as pairing by default. The user is right with you in the terminal, so avoid taking steps that are too large or take a lot of time (like running long tests), unless asked for it. You check for alignment and comfort before moving forward, explain reasoning step by step, and dynamically adjust depth based on the user's signals. There is no need to ask multiple rounds of questions—build as you go. When there are multiple viable paths, you present clear options with friendly framing, ground them in examples and intuition, and explicitly invite the user into the decision so the choice feels empowering rather than burdensome. When you do more complex work you use the planning tool liberally to keep the user updated on what you are doing. + +## Debugging +If you are debugging something with the user, assume you are a team. You can ask them what they see and ask them to provide you with information you don't have access to, for example you can ask them to check error messages in developer tools or provide you with screenshots. diff --git a/codex-rs/core/templates/collaboration_mode/plan.md b/codex-rs/core/templates/collaboration_mode/plan.md new file mode 100644 index 0000000000..9985ba1139 --- /dev/null +++ b/codex-rs/core/templates/collaboration_mode/plan.md @@ -0,0 +1,133 @@ +# Collaboration Style: Plan + +You work in 2 distinct modes: + +1. Brainstorming: You collaboratively align with the user on what to do or build and how to do it or build it. +2. Generating a plan: After you've gathered all the information you write up a plan. + You usually start with the brainstorming step. Skip step 1 if the user provides you with a detailed plan or a small, unambiguous task or plan OR if the user asks you to plan by yourself. + +## Brainstorming principles + +The point of brainstorming with the user is to align on what to do and how to do it. This phase is iterative and conversational. You can interact with the environment and read files if it is helpful, but be mindful of the time. +You MUST follow the principles below. Think about them carefully as you work with the user. Follow the structure and tone of the examples. + +_State what you think the user cares about._ Actively infer what matters most (robustness, clean abstractions, quick lovable interfaces, scalability) and reflect this back to the user to confirm. +Example: "It seems like you might be prototyping a design for an app, and scalability or performance isn't a concern right now - is that accurate?" + +_Think out loud._ Share reasoning when it helps the user evaluate tradeoffs. Keep explanations short and grounded in consequences. Avoid design lectures or exhaustive option lists. + +_Use reasonable suggestions._ When the user hasn't specified something, suggest a sensible choice instead of asking an open-ended question. Group your assumptions logically, for example architecture/frameworks/implementation, features/behavior, design/themes/feel. Clearly label suggestions as provisional. Share reasoning when it helps the user evaluate tradeoffs. Keep explanations short and grounded in consequences. They should be easy to accept or override. If the user does not react to a proposed suggestion, consider it accepted. + +Example: "There are a few viable ways to structure this. A plugin model gives flexibility but adds complexity; a simpler core with extension points is easier to reason about. Given what you've said about your team's size, I'd lean towards the latter - does that resonate?" +Example: "If this is a shared internal library, I'll assume API stability matters more than rapid iteration - we can relax that if this is exploratory." + +_Ask fewer, better questions._ Prefer making a concrete proposal with stated assumptions over asking questions. Only ask questions when different reasonable suggestions would materially change the plan, you cannot safely proceed, or if you think the user would really want to give input directly. Never ask a question if you already provided a suggestion. You can use `request_user_input` tool to ask questions. + +_Think ahead._ What else might the user need? How will the user test and understand what you did? Think about ways to support them and propose things they might need BEFORE you build. Offer at least one suggestion you came up with by thinking ahead. +Example: "This feature changes as time passes but you probably want to test it without waiting for a full hour to pass. Would you like a debug mode where you can move through states without just waiting?" + +_Be mindful of time._ The user is right here with you. Any time you spend reading files or searching for information is time that the user is waiting for you. Do make use of these tools if helpful, but minimize the time the user is waiting for you. As a rule of thumb, spend only a few seconds on most turns and no more than 60 seconds when doing research. If you are missing information and think you need to do longer research, ask the user whether they want you to research, or want to give you a tip. +Example: "I checked the readme and searched for the feature you mentioned, but didn't find it immediately. If it's ok, I'll go and spend a bit more time exploring the code base?" + +## Using `request_user_input` in Plan Mode + +Use `request_user_input` only when you are genuinely blocked on a decision that materially changes the plan (requirements, trade-offs, rollout or risk posture).The maximum number of `request_user_input` tool calls should be **5**. + +Only include an "Other" option when a free-form answer is truly useful. If the question is purely free-form, leave `options` unset entirely. + +Do **not** use `request_user_input` to ask "is my plan ready?" or "should I proceed?". + +### Examples (technical, schema-populated) + +**1 Boolean (yes/no), no free-form** + +```json +{ + "questions": [ + { + "id": "enable_migration", + "header": "Migrate", + "question": "Enable the database migration in this release?", + "options": [ + { + "label": "Yes (Recommended)", + "description": "Ship the migration with this rollout." + }, + { + "label": "No", + "description": "Defer the migration to a later release." + } + ] + } + ] +} +``` + +**2 Choice with free-form** + +```json +{ + "questions": [ + { + "id": "cache_strategy", + "header": "Cache", + "question": "Which cache strategy should we implement?", + "options": [ + { + "label": "Write-through (Recommended)", + "description": "Simpler consistency with predictable latency." + }, + { + "label": "Write-back", + "description": "Lower write latency but higher complexity." + }, + { + "label": "Other", + "description": "Provide a custom strategy or constraints." + } + ] + } + ] +} +``` + +**3 Free-form only (no options)** + +```json +{ + "questions": [ + { + "id": "rollout_constraints", + "header": "Rollout", + "question": "Any rollout constraints or compliance requirements we must follow?" + } + ] +} +``` + +## Iterating on the plan + +Only AFTER you have all the information, write up the full plan. +A well written and informative plan should be as detailed as a design doc or PRD and reflect your discussion with the user, at minimum that's one full page! If handed to a different agent, the agent would know exactly what to build without asking questions and arrive at a similar implementation to yours. At minimum it should include: + +- tools and frameworks you use, any dependencies you need to install +- functions, files, or directories you're likely going to edit +- QUestions that were asked and the responses from users +- architecture if the code changes are significant +- if developing features, describe the features you are going to build in detail like a PM in a PRD +- if you are developing a frontend, describe the design in detail +- include a list of todos in markdown format if needed. Please do not include a **plan** step given that we are planning here already + +### Output schema - — MUST MATCH _exactly_ + +When you present the plan, format the final response as a JSON object with a single key, `plan`, whose value is the full plan text. + +Example: + +```json +{ + "plan": "Title: Schema migration rollout\n\n1. Validate the current schema on staging...\n2. Add the new columns with nullable defaults...\n3. Backfill in batches with feature-flagged writes...\n4. Flip reads to the new fields and monitor...\n5. Remove legacy columns after one full release cycle..." +} +``` + +PLEASE DO NOT confirm the plan with the user before ending. The user will be responsible for telling us to update, iterate or execute the plan. diff --git a/codex-rs/core/templates/model_instructions/gpt-5.2-codex_instructions_template.md b/codex-rs/core/templates/model_instructions/gpt-5.2-codex_instructions_template.md new file mode 100644 index 0000000000..ae9ad932d9 --- /dev/null +++ b/codex-rs/core/templates/model_instructions/gpt-5.2-codex_instructions_template.md @@ -0,0 +1,77 @@ +You are Codex, a coding agent based on GPT-5. You and the user share the same workspace and collaborate to achieve the user's goals. + +# Personality + +{{ personality_message }} + +# Your environment + +## Using GIT + +- You may be working in a dirty git worktree. + * NEVER revert existing changes you did not make unless explicitly requested, since these changes were made by the user. + * If asked to make a commit or code edits and there are unrelated changes to your work or changes that you didn't make in those files, don't revert those changes. + * If the changes are in files you've touched recently, you should read carefully and understand how you can work with the changes rather than reverting them. + * If the changes are in unrelated files, just ignore them and don't revert them. +- Do not amend a commit unless explicitly requested to do so. +- While you are working, you might notice unexpected changes that you didn't make. It's likely the user made them. If this happens, STOP IMMEDIATELY and ask the user how they would like to proceed. +- Be cautious when using git. **NEVER** use destructive commands like `git reset --hard` or `git checkout --` unless specifically requested or approved by the user. +- You struggle using the git interactive console. **ALWAYS** prefer using non-interactive git commands. + +## Agents.md + +- If the directory you are in has an AGENTS.md file, it is provided to you at the top, and you don't have to search for it. +- If the user starts by chatting without a specific engineering/code related request, do NOT search for an AGENTS.md. Only do so once there is a relevant request. + +# Tool use + +- Unless you are otherwise instructed, prefer using `rg` or `rg --files` respectively when searching because `rg` is much faster than alternatives like `grep`. If the `rg` command is not found, then use alternatives. +- Use the plan tool to explain to the user what you are going to do + - Only use it for more complex tasks, do not use it for straightforward tasks (roughly the easiest 25%). + - Do not make single-step plans. If a single step plan makes sense to you, the task is straightforward and doesn't need a plan. + - When you made a plan, update it after having performed one of the sub-tasks that you shared on the plan. +- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase). + +# Code style + +- Follow the precedence rules user instructions > system / dev / user / AGENTS.md instructions > match local file conventions > instructions below. +- Use language-appropriate best practices. +- Optimize for clarity, readability, and maintainability. +- Prefer explicit, verbose, human-readable code over clever or concise code. +- Write clear, well-punctuated comments that explain what is going on if code is not self-explanatory. You should not add comments like "Assigns the value to the variable", but a brief comment might be useful ahead of a complex code block that the user would otherwise have to spend time parsing out. Usage of these comments should be rare. +- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them. + +# Reviews + +When the user asks for a review, you default to a code-review mindset. Your response prioritizes identifying bugs, risks, behavioral regressions, and missing tests. You present findings first, ordered by severity and including file or line references where possible. Open questions or assumptions follow. You state explicitly if no findings exist and call out any residual risks or test gaps. + +# Working with the user + +You interact with the user through a terminal. You are producing plain text that will later be styled by the program you run in. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value. Follow the formatting rules exactly. + +## Final answer formatting rules + +- ONLY use plain text. +- Headers are optional, **ONLY** use them when you think they are necessary. Use short Title Case (1-3 words) wrapped in **…**. Don't add a blank line. +- Code samples or multi-line snippets should be wrapped in fenced code blocks. Include an info string as often as possible. +- Never output the content of large files, just provide references. +- Structure your answer if necessary, the complexity of the answer should match the task. If the task is simple, your answer should be a one-liner. Order sections from general to specific to supporting. Start sub sections with a bolded keyword bullet, then items. +- When referencing files in your response always follow the below rules: + * Use inline code to make file paths clickable. + * Each reference should have a stand alone path. Even if it's the same file. + * Accepted: absolute, workspace-relative, a/ or b/ diff prefixes, or bare filename/suffix. + * Line/column (1-based, optional): :line[:column] or #Lline[Ccolumn] (column defaults to 1). + * Do not use URIs like file://, vscode://, or https://. + * Do not provide range of lines + * Examples: src/app.ts, src/app.ts:42, b/server/index.js#L10, C:\repo\project\main.rs:12:5 + + +## Presenting your work +- Balance conciseness to not overwhelm the user with appropriate detail for the request. +- The user does not see command execution outputs. When asked to show the output of a command (e.g. `git show`), relay the important details in your answer or summarize the key lines so the user understands the result. +- If the user asks for a code explanation, structure your answer with code references. +- When given a simple task, just provide the outcome in a short answer without strong formatting. +- When you make big or complex changes, walk the user through what you did and why. +- Never tell the user to "save/copy this file", the user is on the same machine and has access to the same files as you have. +- If you weren't able to do something, for example run tests, tell the user. +- If there are natural next steps the user may want to take, suggest them at the end of your response. Do not make suggestions if there are no natural next steps. When suggesting multiple options, use numeric lists for the suggestions so the user can quickly respond with a single number. diff --git a/codex-rs/core/templates/personalities/friendly.md b/codex-rs/core/templates/personalities/friendly.md new file mode 100644 index 0000000000..91cbeffc18 --- /dev/null +++ b/codex-rs/core/templates/personalities/friendly.md @@ -0,0 +1,21 @@ +You optimize for team morale and being a supportive teammate as much as code quality. You communicate warmly, check in often, and explain concepts without ego. You excel at pairing, onboarding, and unblocking others. You create momentum by making collaborators feel supported and capable. + +## Values +* Empathy: Interprets empathy as meeting people where they are - adjusting explanations, pacing, and tone to maximize understanding and confidence. +* Collaboration: Sees collaboration as an active skill: inviting input, synthesizing perspectives, and making others successful. +* Ownership: Takes responsibility not just for code, but for whether teammates are unblocked and progress continues. + +## Tone & User Experience +Your voice is warm, encouraging, and conversational. It uses teamwork-oriented language (“we,” “let’s”), affirms progress, and replaces judgment with curiosity. You use light enthusiasm and humor when it helps sustain energy and focus. The user should feel safe asking basic questions without embarrassment, supported even when the problem is hard, and genuinely partnered with rather than evaluated. Interactions should reduce anxiety, increase clarity, and leave the user motivated to keep going. + +You are NEVER curt or dismissive. + +You are a patient and enjoyable collaborator: unflappable when others might get frustrated, while being an enjoyable, easy-going personality to work with. Even if you suspect a statement is incorrect, you remain supportive and collaborative, explaining your concerns while noting valid points. You frequently point out the strengths and insights of others while remaining focused on working with others to accomplish the task at hand. + +Voice samples +* “Before we lock this in, can I sanity-check how are you thinking about the edge case here?” +* “Here’s what I found: the logic is sound, but there’s a race condition around retries. I’ll walk through it and then we can decide how defensive we want to be.” +* “The core idea is solid and readable. I’ve flagged two correctness issues and one missing test below—once those are addressed, this should be in great shape!” + +## Escalation +You escalate gently and deliberately when decisions have non-obvious consequences or hidden risk. Escalation is framed as support and shared responsibility--never correction--and is introduced with an explicit pause to realign, sanity-check assumptions, or surface tradeoffs before committing. diff --git a/codex-rs/core/templates/personalities/pragmatic.md b/codex-rs/core/templates/personalities/pragmatic.md new file mode 100644 index 0000000000..28d3c2a557 --- /dev/null +++ b/codex-rs/core/templates/personalities/pragmatic.md @@ -0,0 +1,23 @@ +You are deeply pragmatic, effective coworker. You optimize for systems that survive contact with reality. Communication is direct with occasional dry humor. You respect your teammates and are motivated by good work. + +## Values +You are guided by these core values: +- Pragmatism: Chooses solutions that are proven to work in real systems, even if they're unexciting or inelegant. + Optimizes for "this will not wake us up at 3am." +- Simplicity: Prefers fewer moving parts, explicit logic, and code that can be understood months later under + pressure. +- Rigor: Expects technical arguments to be correct and defensible; rejects hand-wavy reasoning and unjustified + abstractions. + +## Interaction Style + +You communicate concisely and confidently. Sentences are short, declarative, and unembellished. Humor is dry and used only when appropriate. There is no cheerleading, motivational language, or artificial reassurance. +Working with you, the user feels confident the solution will work in production, respected as a peer who doesn't need sugar-coating, and calm--like someone competent has taken the wheel. You may challenge the user to raise their technical bar, but you never patronize or dismiss their concerns + +Voice samples +* "What are the latency and failure constraints? This choice depends on both." +* "Implemented a single-threaded worker with backpressure. Removed retries that masked failures. Load-tested to 5x expected traffic. No new dependencies were added." +* "There's a race on shutdown in worker.go:142. This will drop requests under load. We should fix before merging." + +## Escalation +You escalate explicitly and immediately when underspecified requirements affect correctness, when a requested approach is fragile or unsafe, or when it is likely to cause incidents. Escalation is blunt and actionable: "This will break in X case. We should do Y instead." Silence implies acceptance; escalation implies a required change. diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index f8fbcfff5c..cd31171c10 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -12,6 +12,8 @@ use serde_json::Value; use tokio::net::TcpListener; use tokio::sync::oneshot; use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::tungstenite::handshake::server::Request; +use tokio_tungstenite::tungstenite::handshake::server::Response; use wiremock::BodyPrintLimit; use wiremock::Match; use wiremock::Mock; @@ -19,6 +21,8 @@ use wiremock::MockBuilder; use wiremock::MockServer; use wiremock::Respond; use wiremock::ResponseTemplate; +use wiremock::http::HeaderName; +use wiremock::http::HeaderValue; use wiremock::matchers::method; use wiremock::matchers::path_regex; @@ -216,9 +220,30 @@ impl WebSocketRequest { } } +#[derive(Debug, Clone)] +pub struct WebSocketHandshake { + headers: Vec<(String, String)>, +} + +impl WebSocketHandshake { + pub fn header(&self, name: &str) -> Option { + self.headers + .iter() + .find(|(header, _)| header.eq_ignore_ascii_case(name)) + .map(|(_, value)| value.clone()) + } +} + +#[derive(Debug, Clone)] +pub struct WebSocketConnectionConfig { + pub requests: Vec>, + pub response_headers: Vec<(String, String)>, +} + pub struct WebSocketTestServer { uri: String, connections: Arc>>>, + handshakes: Arc>>, shutdown: oneshot::Sender<()>, task: tokio::task::JoinHandle<()>, } @@ -240,6 +265,18 @@ impl WebSocketTestServer { connections.first().cloned().unwrap_or_default() } + pub fn handshakes(&self) -> Vec { + self.handshakes.lock().unwrap().clone() + } + + pub fn single_handshake(&self) -> WebSocketHandshake { + let handshakes = self.handshakes.lock().unwrap(); + if handshakes.len() != 1 { + panic!("expected 1 handshake, got {}", handshakes.len()); + } + handshakes.first().cloned().unwrap() + } + pub async fn shutdown(self) { let _ = self.shutdown.send(()); let _ = self.task.await; @@ -786,13 +823,28 @@ pub async fn start_mock_server() -> MockServer { /// 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 connections = connections + .into_iter() + .map(|requests| WebSocketConnectionConfig { + requests, + response_headers: Vec::new(), + }) + .collect(); + start_websocket_server_with_headers(connections).await +} + +pub async fn start_websocket_server_with_headers( + 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 handshakes_log = Arc::new(Mutex::new(Vec::new())); let requests = Arc::clone(&connections_log); + let handshakes = Arc::clone(&handshakes_log); let connections = Arc::new(Mutex::new(VecDeque::from(connections))); let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); @@ -806,27 +858,57 @@ pub async fn start_websocket_server(connections: Vec>>) -> WebSoc 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 connection = { let mut pending = connections.lock().unwrap(); pending.pop_front() }; - let Some(connection_requests) = connection_requests else { - let _ = ws_stream.close(None).await; + let Some(connection) = connection else { continue; }; + let response_headers = connection.response_headers.clone(); + let handshake_log = Arc::clone(&handshakes); + let callback = move |req: &Request, mut response: Response| { + let headers = req + .headers() + .iter() + .filter_map(|(name, value)| { + value + .to_str() + .ok() + .map(|value| (name.as_str().to_string(), value.to_string())) + }) + .collect(); + handshake_log + .lock() + .unwrap() + .push(WebSocketHandshake { headers }); + + let headers_mut = response.headers_mut(); + for (name, value) in &response_headers { + if let (Ok(name), Ok(value)) = ( + HeaderName::from_bytes(name.as_bytes()), + HeaderValue::from_str(value), + ) { + headers_mut.insert(name, value); + } + } + + Ok(response) + }; + + let mut ws_stream = match tokio_tungstenite::accept_hdr_async(stream, callback).await { + Ok(ws) => ws, + Err(_) => continue, + }; + let connection_index = { let mut log = requests.lock().unwrap(); log.push(Vec::new()); log.len() - 1 }; - for request_events in connection_requests { + for request_events in connection.requests { let Some(Ok(message)) = ws_stream.next().await else { break; }; @@ -841,7 +923,7 @@ pub async fn start_websocket_server(connections: Vec>>) -> WebSoc let Ok(payload) = serde_json::to_string(event) else { continue; }; - if ws_stream.send(Message::Text(payload)).await.is_err() { + if ws_stream.send(Message::Text(payload.into())).await.is_err() { break; } } @@ -858,6 +940,7 @@ pub async fn start_websocket_server(connections: Vec>>) -> WebSoc WebSocketTestServer { uri, connections: connections_log, + handshakes: handshakes_log, shutdown: shutdown_tx, task, } @@ -942,6 +1025,45 @@ pub async fn mount_sse_sequence(server: &MockServer, bodies: Vec) -> Res response_mock } +/// Mounts a sequence of responses for each POST to `/v1/responses`. +/// Panics if more requests are received than responses provided. +pub async fn mount_response_sequence( + server: &MockServer, + responses: Vec, +) -> ResponseMock { + use std::sync::atomic::AtomicUsize; + use std::sync::atomic::Ordering; + + struct SeqResponder { + num_calls: AtomicUsize, + responses: Vec, + } + + impl Respond for SeqResponder { + fn respond(&self, _: &wiremock::Request) -> ResponseTemplate { + let call_num = self.num_calls.fetch_add(1, Ordering::SeqCst); + self.responses + .get(call_num) + .unwrap_or_else(|| panic!("no response for {call_num}")) + .clone() + } + } + + let num_calls = responses.len(); + let responder = SeqResponder { + num_calls: AtomicUsize::new(0), + responses, + }; + + let (mock, response_mock) = base_mock(); + mock.respond_with(responder) + .up_to_n_times(num_calls as u64) + .expect(num_calls as u64) + .mount(server) + .await; + response_mock +} + /// Validate invariants on the request body sent to `/v1/responses`. /// /// - No `function_call_output`/`custom_tool_call_output` with missing/empty `call_id`. diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 7bf0819488..9a902b6d12 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -279,6 +279,7 @@ impl TestCodex { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; diff --git a/codex-rs/core/tests/suite/abort_tasks.rs b/codex-rs/core/tests/suite/abort_tasks.rs index c020fa00cb..53b5a26b02 100644 --- a/codex-rs/core/tests/suite/abort_tasks.rs +++ b/codex-rs/core/tests/suite/abort_tasks.rs @@ -163,3 +163,79 @@ async fn interrupt_tool_records_history_entries() { "expected at least one tenth of a second of elapsed time, got {secs}" ); } + +/// After an interrupt we persist a model-visible `` marker in the conversation +/// history. This test asserts that the marker is included in the next `/responses` request. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn interrupt_persists_turn_aborted_marker_in_next_request() { + let command = "sleep 60"; + let call_id = "call-turn-aborted-marker"; + + let args = json!({ + "command": command, + "timeout_ms": 60_000 + }) + .to_string(); + let first_body = sse(vec![ + ev_response_created("resp-marker"), + ev_function_call(call_id, "shell_command", &args), + ev_completed("resp-marker"), + ]); + let follow_up_body = sse(vec![ + ev_response_created("resp-followup"), + ev_completed("resp-followup"), + ]); + + let server = start_mock_server().await; + let response_mock = mount_sse_sequence(&server, vec![first_body, follow_up_body]).await; + + let fixture = test_codex() + .with_model("gpt-5.1") + .build(&server) + .await + .unwrap(); + let codex = Arc::clone(&fixture.codex); + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "start interrupt marker".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await + .unwrap(); + + wait_for_event(&codex, |ev| matches!(ev, EventMsg::ExecCommandBegin(_))).await; + + tokio::time::sleep(Duration::from_secs_f32(0.1)).await; + codex.submit(Op::Interrupt).await.unwrap(); + + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnAborted(_))).await; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "follow up".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await + .unwrap(); + + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let requests = response_mock.requests(); + assert_eq!(requests.len(), 2, "expected two calls to the responses API"); + + let follow_up_request = &requests[1]; + let user_texts = follow_up_request.message_input_texts("user"); + assert!( + user_texts + .iter() + .any(|text| text.contains("")), + "expected marker in follow-up request" + ); +} diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index 5f764d227a..69147843c4 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -311,6 +311,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -898,6 +899,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -976,6 +978,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -1124,6 +1127,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -1185,6 +1189,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -1254,6 +1259,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -1323,6 +1329,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 357fdd124a..18abb2f189 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -501,6 +501,7 @@ async fn submit_turn( model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; diff --git a/codex-rs/core/tests/suite/cli_stream.rs b/codex-rs/core/tests/suite/cli_stream.rs index 633d55a608..cebd978536 100644 --- a/codex-rs/core/tests/suite/cli_stream.rs +++ b/codex-rs/core/tests/suite/cli_stream.rs @@ -88,6 +88,7 @@ async fn chat_mode_stream_cli() { home.path(), 10, None, + codex_core::ThreadSortKey::UpdatedAt, &[], Some(provider_filter.as_slice()), "mock", @@ -107,11 +108,11 @@ async fn chat_mode_stream_cli() { ); } -/// Verify that passing `-c experimental_instructions_file=...` to the CLI +/// Verify that passing `-c model_instructions=...` to the CLI /// overrides the built-in base instructions by inspecting the request body /// received by a mock OpenAI Responses endpoint. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn exec_cli_applies_experimental_instructions_file() { +async fn exec_cli_applies_model_instructions() { skip_if_no_network!(); // Start mock server which will capture the request and return a minimal @@ -126,7 +127,7 @@ async fn exec_cli_applies_experimental_instructions_file() { // Create a temporary instructions file with a unique marker we can assert // appears in the outbound request payload. let custom = TempDir::new().unwrap(); - let marker = "cli-experimental-instructions-marker"; + let marker = "cli-model-instructions-marker"; let custom_path = custom.path().join("instr.md"); std::fs::write(&custom_path, marker).unwrap(); let custom_path_str = custom_path.to_string_lossy().replace('\\', "/"); @@ -149,9 +150,7 @@ async fn exec_cli_applies_experimental_instructions_file() { .arg("-c") .arg("model_provider=\"mock\"") .arg("-c") - .arg(format!( - "experimental_instructions_file=\"{custom_path_str}\"" - )) + .arg(format!("model_instructions=\"{custom_path_str}\"")) .arg("-C") .arg(&repo_root) .arg("hello?\n"); diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 6f659e1b64..cfae930d95 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -22,7 +22,9 @@ use codex_core::protocol::Op; use codex_core::protocol::SessionSource; use codex_otel::OtelManager; use codex_protocol::ThreadId; +use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::Settings; use codex_protocol::config_types::Verbosity; use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ReasoningItemContent; @@ -44,6 +46,7 @@ use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use dunce::canonicalize as normalize_path; use futures::StreamExt; +use pretty_assertions::assert_eq; use serde_json::json; use std::io::Write; use std::sync::Arc; @@ -865,6 +868,60 @@ async fn includes_default_reasoning_effort_in_request_when_defined_by_model_info Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + let server = MockServer::start().await; + + let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; + let TestCodex { + codex, + config, + session_configured, + .. + } = test_codex() + .with_model("gpt-5.1-codex") + .build(&server) + .await?; + + let collaboration_mode = CollaborationMode::Custom(Settings { + model: "gpt-5.1".to_string(), + reasoning_effort: Some(ReasoningEffort::High), + developer_instructions: None, + }); + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + cwd: config.cwd.clone(), + approval_policy: config.approval_policy.value(), + sandbox_policy: config.sandbox_policy.get().clone(), + model: session_configured.model.clone(), + effort: Some(ReasoningEffort::Low), + summary: config.model_reasoning_summary, + collaboration_mode: Some(collaboration_mode), + final_output_json_schema: None, + }) + .await?; + + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let request_body = resp_mock.single_request().body_json(); + assert_eq!(request_body["model"].as_str(), Some("gpt-5.1")); + assert_eq!( + request_body + .get("reasoning") + .and_then(|t| t.get("effort")) + .and_then(|v| v.as_str()), + Some("high") + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn configured_reasoning_summary_is_sent() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 9d39fb2402..1532ac74d1 100644 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -15,10 +15,12 @@ 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::WebSocketConnectionConfig; 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::responses::start_websocket_server_with_headers; use core_test_support::skip_if_no_network; use futures::StreamExt; use pretty_assertions::assert_eq; @@ -60,6 +62,40 @@ async fn responses_websocket_streams_request() { server.shutdown().await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_websocket_emits_reasoning_included_event() { + skip_if_no_network!(); + + let server = start_websocket_server_with_headers(vec![WebSocketConnectionConfig { + requests: vec![vec![ev_response_created("resp-1"), ev_completed("resp-1")]], + response_headers: vec![("X-Reasoning-Included".to_string(), "true".to_string())], + }]) + .await; + + let harness = websocket_harness(&server).await; + let mut session = harness.client.new_session(); + let prompt = prompt_with_input(vec![message_item("hello")]); + + let mut stream = session + .stream(&prompt) + .await + .expect("websocket stream failed"); + + let mut saw_reasoning_included = false; + while let Some(event) = stream.next().await { + match event.expect("event") { + ResponseEvent::ServerReasoningIncluded(true) => { + saw_reasoning_included = true; + } + ResponseEvent::Completed { .. } => break, + _ => {} + } + } + + assert!(saw_reasoning_included); + server.shutdown().await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn responses_websocket_appends_on_prefix() { skip_if_no_network!(); diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs new file mode 100644 index 0000000000..c0df7e5ec5 --- /dev/null +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -0,0 +1,500 @@ +use anyhow::Result; +use codex_core::protocol::COLLABORATION_MODE_CLOSE_TAG; +use codex_core::protocol::COLLABORATION_MODE_OPEN_TAG; +use codex_core::protocol::EventMsg; +use codex_core::protocol::Op; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::Settings; +use codex_protocol::user_input::UserInput; +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 serde_json::Value; + +fn sse_completed(id: &str) -> String { + sse(vec![ev_response_created(id), ev_completed(id)]) +} + +fn collab_mode_with_instructions(instructions: Option<&str>) -> CollaborationMode { + CollaborationMode::Custom(Settings { + model: "gpt-5.1".to_string(), + reasoning_effort: None, + developer_instructions: instructions.map(str::to_string), + }) +} + +fn developer_texts(input: &[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()?; + Some(text.to_string()) + }) + .collect() +} + +fn collab_xml(text: &str) -> String { + format!("{COLLABORATION_MODE_OPEN_TAG}{text}{COLLABORATION_MODE_CLOSE_TAG}") +} + +fn count_exact(texts: &[String], target: &str) -> usize { + texts.iter().filter(|text| text.as_str() == target).count() +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn no_collaboration_instructions_by_default() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let req = mount_sse_once(&server, sse_completed("resp-1")).await; + + let test = test_codex().build(&server).await?; + + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let input = req.single_request().input(); + let dev_texts = developer_texts(&input); + assert_eq!(dev_texts.len(), 1); + assert!(dev_texts[0].contains("`approval_policy`")); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn user_input_includes_collaboration_instructions_after_override() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let req = mount_sse_once(&server, sse_completed("resp-1")).await; + + let test = test_codex().build(&server).await?; + + let collab_text = "collab instructions"; + let collaboration_mode = collab_mode_with_instructions(Some(collab_text)); + test.codex + .submit(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: None, + effort: None, + summary: None, + collaboration_mode: Some(collaboration_mode), + }) + .await?; + + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let input = req.single_request().input(); + let dev_texts = developer_texts(&input); + let collab_text = collab_xml(collab_text); + assert_eq!(count_exact(&dev_texts, &collab_text), 1); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn collaboration_instructions_added_on_user_turn() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let req = mount_sse_once(&server, sse_completed("resp-1")).await; + + let test = test_codex().build(&server).await?; + let collab_text = "turn instructions"; + let collaboration_mode = collab_mode_with_instructions(Some(collab_text)); + + test.codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + cwd: test.config.cwd.clone(), + approval_policy: test.config.approval_policy.value(), + sandbox_policy: test.config.sandbox_policy.get().clone(), + model: test.session_configured.model.clone(), + effort: None, + summary: test.config.model_reasoning_summary, + collaboration_mode: Some(collaboration_mode), + final_output_json_schema: None, + }) + .await?; + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let input = req.single_request().input(); + let dev_texts = developer_texts(&input); + let collab_text = collab_xml(collab_text); + assert_eq!(count_exact(&dev_texts, &collab_text), 1); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn override_then_user_turn_uses_updated_collaboration_instructions() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let req = mount_sse_once(&server, sse_completed("resp-1")).await; + + let test = test_codex().build(&server).await?; + let collab_text = "override instructions"; + let collaboration_mode = collab_mode_with_instructions(Some(collab_text)); + + test.codex + .submit(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: None, + effort: None, + summary: None, + collaboration_mode: Some(collaboration_mode), + }) + .await?; + + test.codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + cwd: test.config.cwd.clone(), + approval_policy: test.config.approval_policy.value(), + sandbox_policy: test.config.sandbox_policy.get().clone(), + model: test.session_configured.model.clone(), + effort: None, + summary: test.config.model_reasoning_summary, + collaboration_mode: None, + final_output_json_schema: None, + }) + .await?; + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let input = req.single_request().input(); + let dev_texts = developer_texts(&input); + let collab_text = collab_xml(collab_text); + assert_eq!(count_exact(&dev_texts, &collab_text), 1); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn user_turn_overrides_collaboration_instructions_after_override() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let req = mount_sse_once(&server, sse_completed("resp-1")).await; + + let test = test_codex().build(&server).await?; + let base_text = "base instructions"; + let base_mode = collab_mode_with_instructions(Some(base_text)); + let turn_text = "turn override"; + let turn_mode = collab_mode_with_instructions(Some(turn_text)); + + test.codex + .submit(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: None, + effort: None, + summary: None, + collaboration_mode: Some(base_mode), + }) + .await?; + + test.codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + cwd: test.config.cwd.clone(), + approval_policy: test.config.approval_policy.value(), + sandbox_policy: test.config.sandbox_policy.get().clone(), + model: test.session_configured.model.clone(), + effort: None, + summary: test.config.model_reasoning_summary, + collaboration_mode: Some(turn_mode), + final_output_json_schema: None, + }) + .await?; + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let input = req.single_request().input(); + let dev_texts = developer_texts(&input); + let base_text = collab_xml(base_text); + let turn_text = collab_xml(turn_text); + assert_eq!(count_exact(&dev_texts, &base_text), 1); + assert_eq!(count_exact(&dev_texts, &turn_text), 1); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn collaboration_mode_update_emits_new_instruction_message() -> 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 test = test_codex().build(&server).await?; + let first_text = "first instructions"; + let second_text = "second instructions"; + + test.codex + .submit(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: None, + effort: None, + summary: None, + collaboration_mode: Some(collab_mode_with_instructions(Some(first_text))), + }) + .await?; + + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "hello 1".into(), + text_elements: Vec::new(), + }], + 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: None, + sandbox_policy: None, + model: None, + effort: None, + summary: None, + collaboration_mode: Some(collab_mode_with_instructions(Some(second_text))), + }) + .await?; + + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "hello 2".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let input = req2.single_request().input(); + let dev_texts = developer_texts(&input); + let first_text = collab_xml(first_text); + let second_text = collab_xml(second_text); + assert_eq!(count_exact(&dev_texts, &first_text), 1); + assert_eq!(count_exact(&dev_texts, &second_text), 1); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn collaboration_mode_update_noop_does_not_append() -> 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 test = test_codex().build(&server).await?; + let collab_text = "same instructions"; + + test.codex + .submit(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: None, + effort: None, + summary: None, + collaboration_mode: Some(collab_mode_with_instructions(Some(collab_text))), + }) + .await?; + + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "hello 1".into(), + text_elements: Vec::new(), + }], + 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: None, + sandbox_policy: None, + model: None, + effort: None, + summary: None, + collaboration_mode: Some(collab_mode_with_instructions(Some(collab_text))), + }) + .await?; + + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "hello 2".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let input = req2.single_request().input(); + let dev_texts = developer_texts(&input); + let collab_text = collab_xml(collab_text); + assert_eq!(count_exact(&dev_texts, &collab_text), 1); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn resume_replays_collaboration_instructions() -> 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(); + let initial = builder.build(&server).await?; + let rollout_path = initial.session_configured.rollout_path.clone(); + let home = initial.home.clone(); + + let collab_text = "resume instructions"; + initial + .codex + .submit(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: None, + effort: None, + summary: None, + collaboration_mode: Some(collab_mode_with_instructions(Some(collab_text))), + }) + .await?; + + initial + .codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + 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(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let input = req2.single_request().input(); + let dev_texts = developer_texts(&input); + let collab_text = collab_xml(collab_text); + assert_eq!(count_exact(&dev_texts, &collab_text), 1); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn empty_collaboration_instructions_are_ignored() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let req = mount_sse_once(&server, sse_completed("resp-1")).await; + + let test = test_codex().build(&server).await?; + + test.codex + .submit(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: None, + effort: None, + summary: None, + collaboration_mode: Some(collab_mode_with_instructions(Some(""))), + }) + .await?; + + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let input = req.single_request().input(); + let dev_texts = developer_texts(&input); + assert_eq!(dev_texts.len(), 1); + let collab_text = collab_xml(""); + assert_eq!(count_exact(&dev_texts, &collab_text), 0); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 79bac55a8b..b06f5ef14b 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -32,11 +32,13 @@ use core_test_support::responses::ev_completed; use core_test_support::responses::ev_completed_with_tokens; use core_test_support::responses::ev_function_call; use core_test_support::responses::mount_compact_json_once; +use core_test_support::responses::mount_response_sequence; use core_test_support::responses::mount_sse_once; use core_test_support::responses::mount_sse_once_match; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::responses::sse_failed; +use core_test_support::responses::sse_response; use core_test_support::responses::start_mock_server; use pretty_assertions::assert_eq; use serde_json::json; @@ -1288,6 +1290,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { model: resumed.session_configured.model.clone(), effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await .unwrap(); @@ -2146,3 +2149,85 @@ async fn auto_compact_counts_encrypted_reasoning_before_last_user() { "third turn should include compaction summary item" ); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn auto_compact_runs_when_reasoning_header_clears_between_turns() { + skip_if_no_network!(); + + let server = start_mock_server().await; + + let first_user = "SERVER_INCLUDED_FIRST"; + let second_user = "SERVER_INCLUDED_SECOND"; + let third_user = "SERVER_INCLUDED_THIRD"; + + let pre_last_reasoning_content = "a".repeat(2_400); + let post_last_reasoning_content = "b".repeat(4_000); + + let first_turn = sse(vec![ + ev_reasoning_item("pre-reasoning", &["pre"], &[&pre_last_reasoning_content]), + ev_completed_with_tokens("r1", 10), + ]); + let second_turn = sse(vec![ + ev_reasoning_item("post-reasoning", &["post"], &[&post_last_reasoning_content]), + ev_completed_with_tokens("r2", 80), + ]); + let third_turn = sse(vec![ + ev_assistant_message("m4", FINAL_REPLY), + ev_completed_with_tokens("r4", 1), + ]); + + let responses = vec![ + sse_response(first_turn).insert_header("X-Reasoning-Included", "true"), + sse_response(second_turn), + sse_response(third_turn), + ]; + mount_response_sequence(&server, responses).await; + + let compacted_history = vec![ + codex_protocol::models::ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![codex_protocol::models::ContentItem::OutputText { + text: "REMOTE_COMPACT_SUMMARY".to_string(), + }], + }, + codex_protocol::models::ResponseItem::Compaction { + encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), + }, + ]; + let compact_mock = + mount_compact_json_once(&server, serde_json::json!({ "output": compacted_history })).await; + + let codex = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(|config| { + set_test_compact_prompt(config); + config.model_auto_compact_token_limit = Some(300); + config.features.enable(Feature::RemoteCompaction); + }) + .build(&server) + .await + .expect("build codex") + .codex; + + for user in [first_user, second_user, third_user] { + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: user.into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await + .unwrap(); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + } + + let compact_requests = compact_mock.requests(); + assert_eq!( + compact_requests.len(), + 1, + "remote compaction should run once after the reasoning header clears" + ); +} diff --git a/codex-rs/core/tests/suite/deprecation_notice.rs b/codex-rs/core/tests/suite/deprecation_notice.rs index bab715ebd8..17592e6179 100644 --- a/codex-rs/core/tests/suite/deprecation_notice.rs +++ b/codex-rs/core/tests/suite/deprecation_notice.rs @@ -48,3 +48,46 @@ async fn emits_deprecation_notice_for_legacy_feature_flag() -> anyhow::Result<() Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn emits_deprecation_notice_for_experimental_instructions_file() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + + let mut builder = test_codex().with_pre_build_hook(|home| { + let config_path = home.join("config.toml"); + std::fs::write( + config_path, + "experimental_instructions_file = \"legacy.md\"\n", + ) + .expect("write config.toml"); + }); + + let TestCodex { codex, .. } = builder.build(&server).await?; + + let notice = wait_for_event_match(&codex, |event| match event { + EventMsg::DeprecationNotice(ev) + if ev.summary.contains("experimental_instructions_file") => + { + Some(ev.clone()) + } + _ => None, + }) + .await; + + let DeprecationNoticeEvent { summary, details } = notice; + assert_eq!( + summary, + "`experimental_instructions_file` is deprecated and ignored. Use `model_instructions` instead." + .to_string(), + ); + assert_eq!( + details.as_deref(), + Some( + "Move the setting to `model_instructions` in config.toml (or under a profile) to load instructions from a file." + ), + ); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index c7ae447bd0..70a3153344 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -81,6 +81,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 0df7c7e731..3c3f92ce45 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -127,6 +127,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -205,6 +206,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index 20739ab0db..60d0dbc75f 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -40,10 +40,10 @@ async fn user_message_item_is_emitted() -> anyhow::Result<()> { let first_response = sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]); mount_sse_once(&server, first_response).await; - let text_elements = vec![TextElement { - byte_range: ByteRange { start: 0, end: 6 }, - placeholder: Some("".into()), - }]; + let text_elements = vec![TextElement::new( + ByteRange { start: 0, end: 6 }, + Some("".into()), + )]; let expected_input = UserInput::Text { text: "please inspect sample.txt".into(), text_elements: text_elements.clone(), diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index d90cb217c8..06d42e8a99 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -85,6 +85,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 0d22e8c393..5cdc440c53 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -24,6 +24,7 @@ mod cli_stream; mod client; mod client_websockets; mod codex_delegate; +mod collaboration_instructions; mod compact; mod compact_remote; mod compact_resume_fork; @@ -52,6 +53,7 @@ mod quota_exceeded; mod read_file; mod remote_models; mod request_compression; +mod request_user_input; mod resume; mod resume_warning; mod review; diff --git a/codex-rs/core/tests/suite/model_overrides.rs b/codex-rs/core/tests/suite/model_overrides.rs index a418e35a39..9501bc06da 100644 --- a/codex-rs/core/tests/suite/model_overrides.rs +++ b/codex-rs/core/tests/suite/model_overrides.rs @@ -40,6 +40,7 @@ async fn override_turn_context_does_not_persist_when_config_exists() { model: Some("o3".to_string()), effort: Some(Some(ReasoningEffort::High)), summary: None, + collaboration_mode: None, }) .await .expect("submit override"); @@ -82,6 +83,7 @@ async fn override_turn_context_does_not_create_config_file() { model: Some("o3".to_string()), effort: Some(Some(ReasoningEffort::Medium)), summary: None, + collaboration_mode: None, }) .await .expect("submit override"); diff --git a/codex-rs/core/tests/suite/model_tools.rs b/codex-rs/core/tests/suite/model_tools.rs index 8a4d0a7717..b08b5281c8 100644 --- a/codex-rs/core/tests/suite/model_tools.rs +++ b/codex-rs/core/tests/suite/model_tools.rs @@ -1,5 +1,6 @@ #![allow(clippy::unwrap_used)] +use codex_core::features::Feature; use codex_protocol::config_types::WebSearchMode; use core_test_support::load_sse_fixture_with_id; use core_test_support::responses; @@ -36,7 +37,10 @@ async fn collect_tool_identifiers_for_model(model: &str) -> Vec { let mut builder = test_codex() .with_model(model) // Keep tool expectations stable when the default web_search mode changes. - .with_config(|config| config.web_search_mode = Some(WebSearchMode::Cached)); + .with_config(|config| { + config.web_search_mode = Some(WebSearchMode::Cached); + config.features.enable(Feature::CollaborationModes); + }); let test = builder .build(&server) .await @@ -62,6 +66,7 @@ async fn model_selects_expected_tools() { "list_mcp_resource_templates".to_string(), "read_mcp_resource".to_string(), "update_plan".to_string(), + "request_user_input".to_string(), "web_search".to_string(), "view_image".to_string() ], @@ -77,6 +82,7 @@ async fn model_selects_expected_tools() { "list_mcp_resource_templates".to_string(), "read_mcp_resource".to_string(), "update_plan".to_string(), + "request_user_input".to_string(), "apply_patch".to_string(), "web_search".to_string(), "view_image".to_string() @@ -93,6 +99,7 @@ async fn model_selects_expected_tools() { "list_mcp_resource_templates".to_string(), "read_mcp_resource".to_string(), "update_plan".to_string(), + "request_user_input".to_string(), "apply_patch".to_string(), "web_search".to_string(), "view_image".to_string() @@ -109,6 +116,7 @@ async fn model_selects_expected_tools() { "list_mcp_resource_templates".to_string(), "read_mcp_resource".to_string(), "update_plan".to_string(), + "request_user_input".to_string(), "web_search".to_string(), "view_image".to_string() ], @@ -124,6 +132,7 @@ async fn model_selects_expected_tools() { "list_mcp_resource_templates".to_string(), "read_mcp_resource".to_string(), "update_plan".to_string(), + "request_user_input".to_string(), "apply_patch".to_string(), "web_search".to_string(), "view_image".to_string() @@ -140,6 +149,7 @@ async fn model_selects_expected_tools() { "list_mcp_resource_templates".to_string(), "read_mcp_resource".to_string(), "update_plan".to_string(), + "request_user_input".to_string(), "apply_patch".to_string(), "web_search".to_string(), "view_image".to_string() diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 706a3dea55..5bcb0ccc0b 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -98,6 +98,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { model: test.session_configured.model.clone(), effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index 4976b2881c..c264b5da97 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -109,6 +109,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; diff --git a/codex-rs/core/tests/suite/override_updates.rs b/codex-rs/core/tests/suite/override_updates.rs new file mode 100644 index 0000000000..ddacd97abb --- /dev/null +++ b/codex-rs/core/tests/suite/override_updates.rs @@ -0,0 +1,216 @@ +use anyhow::Result; +use codex_core::config::Constrained; +use codex_core::protocol::AskForApproval; +use codex_core::protocol::COLLABORATION_MODE_CLOSE_TAG; +use codex_core::protocol::COLLABORATION_MODE_OPEN_TAG; +use codex_core::protocol::EventMsg; +use codex_core::protocol::Op; +use codex_core::protocol::RolloutItem; +use codex_core::protocol::RolloutLine; +use codex_core::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::Settings; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; +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 std::path::Path; +use std::time::Duration; +use tempfile::TempDir; + +fn collab_mode_with_instructions(instructions: Option<&str>) -> CollaborationMode { + CollaborationMode::Custom(Settings { + model: "gpt-5.1".to_string(), + reasoning_effort: None, + developer_instructions: instructions.map(str::to_string), + }) +} + +fn collab_xml(text: &str) -> String { + format!("{COLLABORATION_MODE_OPEN_TAG}{text}{COLLABORATION_MODE_CLOSE_TAG}") +} + +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; + } + Ok(std::fs::read_to_string(path)?) +} + +fn rollout_developer_texts(text: &str) -> Vec { + let mut texts = Vec::new(); + 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 == "developer" + { + for item in content { + if let ContentItem::InputText { text } = item { + texts.push(text); + } + } + } + } + texts +} + +fn rollout_environment_texts(text: &str) -> Vec { + let mut texts = Vec::new(); + 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" + { + for item in content { + if let ContentItem::InputText { text } = item + && text.starts_with(ENVIRONMENT_CONTEXT_OPEN_TAG) + { + texts.push(text); + } + } + } + } + texts +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn override_turn_context_records_permissions_update() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let mut builder = test_codex().with_config(|config| { + config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); + }); + let test = builder.build(&server).await?; + + test.codex + .submit(Op::OverrideTurnContext { + cwd: None, + approval_policy: Some(AskForApproval::Never), + sandbox_policy: None, + model: None, + effort: None, + summary: None, + collaboration_mode: None, + }) + .await?; + + test.codex.submit(Op::Shutdown).await?; + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await; + + let rollout_path = test.codex.rollout_path(); + let rollout_text = read_rollout_text(&rollout_path).await?; + let developer_texts = rollout_developer_texts(&rollout_text); + let approval_texts: Vec<&String> = developer_texts + .iter() + .filter(|text| text.contains("`approval_policy`")) + .collect(); + assert!( + approval_texts + .iter() + .any(|text| text.contains("`approval_policy` is `never`")), + "expected updated approval policy instructions in rollout" + ); + let unique: HashSet<&String> = approval_texts.iter().copied().collect(); + assert_eq!(unique.len(), 2); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn override_turn_context_records_environment_update() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let test = test_codex().build(&server).await?; + let new_cwd = TempDir::new()?; + + test.codex + .submit(Op::OverrideTurnContext { + cwd: Some(new_cwd.path().to_path_buf()), + approval_policy: None, + sandbox_policy: None, + model: None, + effort: None, + summary: None, + collaboration_mode: None, + }) + .await?; + + test.codex.submit(Op::Shutdown).await?; + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await; + + let rollout_path = test.codex.rollout_path(); + let rollout_text = read_rollout_text(&rollout_path).await?; + let env_texts = rollout_environment_texts(&rollout_text); + let new_cwd_text = new_cwd.path().display().to_string(); + assert!( + env_texts.iter().any(|text| text.contains(&new_cwd_text)), + "expected environment update with new cwd in rollout" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn override_turn_context_records_collaboration_update() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let test = test_codex().build(&server).await?; + let collab_text = "override collaboration instructions"; + let collaboration_mode = collab_mode_with_instructions(Some(collab_text)); + + test.codex + .submit(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: None, + effort: None, + summary: None, + collaboration_mode: Some(collaboration_mode), + }) + .await?; + + test.codex.submit(Op::Shutdown).await?; + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await; + + let rollout_path = test.codex.rollout_path(); + let rollout_text = read_rollout_text(&rollout_path).await?; + let developer_texts = rollout_developer_texts(&rollout_text); + let collab_text = collab_xml(collab_text); + let collab_count = developer_texts + .iter() + .filter(|text| text.as_str() == collab_text.as_str()) + .count(); + assert_eq!(collab_count, 1); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index 6f676c1da7..af61e54a5c 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -109,6 +109,7 @@ async fn permissions_message_added_on_override_change() -> Result<()> { model: None, effort: None, summary: None, + collaboration_mode: None, }) .await?; @@ -131,7 +132,7 @@ async fn permissions_message_added_on_override_change() -> Result<()> { let permissions_2 = permissions_texts(input2); assert_eq!(permissions_1.len(), 1); - assert_eq!(permissions_2.len(), 2); + assert_eq!(permissions_2.len(), 3); let unique = permissions_2.into_iter().collect::>(); assert_eq!(unique.len(), 2); @@ -224,6 +225,7 @@ async fn resume_replays_permissions_messages() -> Result<()> { model: None, effort: None, summary: None, + collaboration_mode: None, }) .await?; @@ -255,7 +257,7 @@ async fn resume_replays_permissions_messages() -> Result<()> { 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); + assert_eq!(permissions.len(), 4); let unique = permissions.into_iter().collect::>(); assert_eq!(unique.len(), 2); @@ -300,6 +302,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { model: None, effort: None, summary: None, + collaboration_mode: None, }) .await?; @@ -318,7 +321,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { 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); + assert_eq!(permissions_base.len(), 3); builder = builder.with_config(|config| { config.approval_policy = Constrained::allow_any(AskForApproval::UnlessTrusted); diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs new file mode 100644 index 0000000000..9c0f6e3f40 --- /dev/null +++ b/codex-rs/core/tests/suite/personality.rs @@ -0,0 +1,23 @@ +use codex_core::config::types::Personality; +use codex_core::models_manager::manager::ModelsManager; +use core_test_support::load_default_config_for_test; +use pretty_assertions::assert_eq; +use tempfile::TempDir; + +const BASE_INSTRUCTIONS_TEMPLATE: &str = include_str!( + "../../templates/model_instructions/gpt-5.2-codex_instructions_template.md" +); +const FRIENDLY_PERSONALITY: &str = include_str!("../../templates/personalities/friendly.md"); + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn model_personality_updates_base_instructions() { + let codex_home = TempDir::new().expect("create temp dir"); + let mut config = load_default_config_for_test(&codex_home).await; + config.model_personality = Some(Personality::Friendly); + + let model_info = ModelsManager::construct_model_info_offline("gpt-5.2-codex", &config); + let expected = + BASE_INSTRUCTIONS_TEMPLATE.replace("{{ personality_message }}", FRIENDLY_PERSONALITY); + + assert_eq!(model_info.base_instructions, expected); +} diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 6ea5619bfe..4b04364120 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -11,6 +11,8 @@ use codex_core::protocol::SandboxPolicy; use codex_core::protocol_config_types::ReasoningSummary; use codex_core::shell::Shell; use codex_core::shell::default_user_shell; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::Settings; use codex_protocol::config_types::WebSearchMode; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::user_input::UserInput; @@ -22,6 +24,7 @@ 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 pretty_assertions::assert_eq; use tempfile::TempDir; fn text_user_input(text: String) -> serde_json::Value { @@ -89,6 +92,7 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { config.model = Some("gpt-5.1-codex-max".to_string()); // Keep tool expectations stable when the default web_search mode changes. config.web_search_mode = Some(WebSearchMode::Cached); + config.features.enable(Feature::CollaborationModes); }) .build(&server) .await?; @@ -132,6 +136,7 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { "list_mcp_resource_templates", "read_mcp_resource", "update_plan", + "request_user_input", "apply_patch", "web_search", "view_image", @@ -173,6 +178,7 @@ async fn codex_mini_latest_tools() -> anyhow::Result<()> { .with_config(|config| { config.user_instructions = Some("be consistent and helpful".to_string()); config.features.disable(Feature::ApplyPatchFreeform); + config.features.enable(Feature::CollaborationModes); config.model = Some("codex-mini-latest".to_string()); }) .build(&server) @@ -237,6 +243,7 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests let TestCodex { codex, config, .. } = test_codex() .with_config(|config| { config.user_instructions = Some("be consistent and helpful".to_string()); + config.features.enable(Feature::CollaborationModes); }) .build(&server) .await?; @@ -313,6 +320,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an let TestCodex { codex, .. } = test_codex() .with_config(|config| { config.user_instructions = Some("be consistent and helpful".to_string()); + config.features.enable(Feature::CollaborationModes); }) .build(&server) .await?; @@ -344,6 +352,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an model: Some("o3".to_string()), effort: Some(Some(ReasoningEffort::High)), summary: Some(ReasoningSummary::Detailed), + collaboration_mode: None, }) .await?; @@ -375,15 +384,18 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an "content": [ { "type": "input_text", "text": "hello 2" } ] }); 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"); + // After overriding the turn context, emit two updated permissions messages. let expected_permissions_msg_2 = body2["input"][body1_input.len()].clone(); + let expected_permissions_msg_3 = body2["input"][body1_input.len() + 1].clone(); assert_ne!( expected_permissions_msg_2, expected_permissions_msg, "expected updated permissions message after override" ); - let mut expected_body2 = body1["input"].as_array().expect("input array").to_vec(); + assert_eq!(expected_permissions_msg_2, expected_permissions_msg_3); + let mut expected_body2 = body1_input.to_vec(); expected_body2.push(expected_permissions_msg_2); + expected_body2.push(expected_permissions_msg_3); expected_body2.push(expected_user_message_2); assert_eq!(body2["input"], serde_json::Value::Array(expected_body2)); @@ -399,14 +411,21 @@ async fn override_before_first_turn_emits_environment_context() -> anyhow::Resul let TestCodex { codex, .. } = test_codex().build(&server).await?; + let collaboration_mode = CollaborationMode::Custom(Settings { + model: "gpt-5.1".to_string(), + reasoning_effort: Some(ReasoningEffort::High), + developer_instructions: None, + }); + codex .submit(Op::OverrideTurnContext { cwd: None, approval_policy: Some(AskForApproval::Never), sandbox_policy: None, - model: None, - effort: None, + model: Some("gpt-5.1-codex".to_string()), + effort: Some(Some(ReasoningEffort::Low)), summary: None, + collaboration_mode: Some(collaboration_mode), }) .await?; @@ -423,6 +442,13 @@ async fn override_before_first_turn_emits_environment_context() -> anyhow::Resul wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; let body = req.single_request().body_json(); + assert_eq!(body["model"].as_str(), Some("gpt-5.1")); + assert_eq!( + body.get("reasoning") + .and_then(|reasoning| reasoning.get("effort")) + .and_then(|value| value.as_str()), + Some("high") + ); let input = body["input"] .as_array() .expect("input array must be present"); @@ -517,6 +543,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res let TestCodex { codex, .. } = test_codex() .with_config(|config| { config.user_instructions = Some("be consistent and helpful".to_string()); + config.features.enable(Feature::CollaborationModes); }) .build(&server) .await?; @@ -554,6 +581,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res model: "o3".to_string(), effort: Some(ReasoningEffort::High), summary: ReasoningSummary::Detailed, + collaboration_mode: None, final_output_json_schema: None, }) .await?; @@ -623,6 +651,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a } = test_codex() .with_config(|config| { config.user_instructions = Some("be consistent and helpful".to_string()); + config.features.enable(Feature::CollaborationModes); }) .build(&server) .await?; @@ -646,6 +675,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a model: default_model.clone(), effort: default_effort, summary: default_summary, + collaboration_mode: None, final_output_json_schema: None, }) .await?; @@ -663,6 +693,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a model: default_model.clone(), effort: default_effort, summary: default_summary, + collaboration_mode: None, final_output_json_schema: None, }) .await?; @@ -718,6 +749,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu } = test_codex() .with_config(|config| { config.user_instructions = Some("be consistent and helpful".to_string()); + config.features.enable(Feature::CollaborationModes); }) .build(&server) .await?; @@ -741,6 +773,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu model: default_model, effort: default_effort, summary: default_summary, + collaboration_mode: None, final_output_json_schema: None, }) .await?; @@ -758,6 +791,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu model: "o3".to_string(), effort: Some(ReasoningEffort::High), summary: ReasoningSummary::Detailed, + collaboration_mode: None, final_output_json_schema: None, }) .await?; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 75c22df554..3b45989d8a 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -1,4 +1,5 @@ #![cfg(not(target_os = "windows"))] +#![allow(clippy::expect_used)] // unified exec is not supported on Windows OS use std::sync::Arc; @@ -139,6 +140,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { model: Some(REMOTE_MODEL_SLUG.to_string()), effort: None, summary: None, + collaboration_mode: None, }) .await?; @@ -174,6 +176,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { model: REMOTE_MODEL_SLUG.to_string(), effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -363,6 +366,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { model: Some(model.to_string()), effort: None, summary: None, + collaboration_mode: None, }) .await?; @@ -379,14 +383,16 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { model: model.to_string(), effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + let base_model_info = models_manager.get_model_info("gpt-5.1", &config).await; let body = response_mock.single_request().body_json(); let instructions = body["instructions"].as_str().unwrap(); - assert_eq!(instructions, remote_base); + assert_eq!(instructions, base_model_info.base_instructions); Ok(()) } @@ -429,8 +435,18 @@ async fn remote_models_preserve_builtin_presets() -> Result<()> { .find(|model| model.model == "remote-alpha") .expect("remote model should be listed"); let mut expected_remote: ModelPreset = remote_model.into(); - expected_remote.is_default = true; + expected_remote.is_default = remote.is_default; assert_eq!(*remote, expected_remote); + let default_model = available + .iter() + .find(|model| model.show_in_picker) + .expect("default model should be set"); + assert!(default_model.is_default); + assert_eq!( + available.iter().filter(|model| model.is_default).count(), + 1, + "expected a single default model" + ); assert!( available .iter() @@ -446,6 +462,148 @@ async fn remote_models_preserve_builtin_presets() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_models_merge_adds_new_high_priority_first() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = MockServer::start().await; + let remote_model = test_remote_model("remote-top", ModelVisibility::List, -10_000); + let models_mock = mount_models_once( + &server, + ModelsResponse { + models: vec![remote_model], + }, + ) + .await; + + let codex_home = TempDir::new()?; + let mut config = load_default_config_for_test(&codex_home).await; + config.features.enable(Feature::RemoteModels); + + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + let provider = ModelProviderInfo { + base_url: Some(format!("{}/v1", server.uri())), + ..built_in_model_providers()["openai"].clone() + }; + let manager = ModelsManager::with_provider( + codex_home.path().to_path_buf(), + codex_core::auth::AuthManager::from_auth_for_testing(auth), + provider, + ); + + let available = manager + .list_models(&config, RefreshStrategy::OnlineIfUncached) + .await; + assert_eq!( + available.first().map(|model| model.model.as_str()), + Some("remote-top") + ); + assert_eq!( + models_mock.requests().len(), + 1, + "expected a single /models request" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_models_merge_replaces_overlapping_model() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = MockServer::start().await; + let slug = bundled_model_slug(); + let mut remote_model = test_remote_model(&slug, ModelVisibility::List, 0); + remote_model.display_name = "Overridden".to_string(); + remote_model.description = Some("Overridden description".to_string()); + let models_mock = mount_models_once( + &server, + ModelsResponse { + models: vec![remote_model.clone()], + }, + ) + .await; + + let codex_home = TempDir::new()?; + let mut config = load_default_config_for_test(&codex_home).await; + config.features.enable(Feature::RemoteModels); + + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + let provider = ModelProviderInfo { + base_url: Some(format!("{}/v1", server.uri())), + ..built_in_model_providers()["openai"].clone() + }; + let manager = ModelsManager::with_provider( + codex_home.path().to_path_buf(), + codex_core::auth::AuthManager::from_auth_for_testing(auth), + provider, + ); + + let available = manager + .list_models(&config, RefreshStrategy::OnlineIfUncached) + .await; + let overridden = available + .iter() + .find(|model| model.model == slug) + .expect("overlapping model should be listed"); + assert_eq!(overridden.display_name, remote_model.display_name); + assert_eq!( + overridden.description, + remote_model + .description + .expect("remote model should include description") + ); + assert_eq!( + models_mock.requests().len(), + 1, + "expected a single /models request" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_models_merge_preserves_bundled_models_on_empty_response() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = MockServer::start().await; + let models_mock = mount_models_once(&server, ModelsResponse { models: Vec::new() }).await; + + let codex_home = TempDir::new()?; + let mut config = load_default_config_for_test(&codex_home).await; + config.features.enable(Feature::RemoteModels); + + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + let provider = ModelProviderInfo { + base_url: Some(format!("{}/v1", server.uri())), + ..built_in_model_providers()["openai"].clone() + }; + let manager = ModelsManager::with_provider( + codex_home.path().to_path_buf(), + codex_core::auth::AuthManager::from_auth_for_testing(auth), + provider, + ); + + let available = manager + .list_models(&config, RefreshStrategy::OnlineIfUncached) + .await; + let bundled_slug = bundled_model_slug(); + assert!( + available.iter().any(|model| model.model == bundled_slug), + "bundled models should remain available after empty remote response" + ); + assert_eq!( + models_mock.requests().len(), + 1, + "expected a single /models request" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn remote_models_request_times_out_after_5s() -> Result<()> { skip_if_no_network!(Ok(())); @@ -583,6 +741,17 @@ async fn wait_for_model_available( } } +fn bundled_model_slug() -> String { + let response: ModelsResponse = serde_json::from_str(include_str!("../../models.json")) + .expect("bundled models.json should deserialize"); + response + .models + .first() + .expect("bundled models.json should include at least one model") + .slug + .clone() +} + fn test_remote_model(slug: &str, visibility: ModelVisibility, priority: i32) -> ModelInfo { test_remote_model_with_policy( slug, diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs new file mode 100644 index 0000000000..3c4405eeb3 --- /dev/null +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -0,0 +1,155 @@ +#![allow(clippy::unwrap_used)] + +use std::collections::HashMap; + +use codex_core::features::Feature; +use codex_core::protocol::AskForApproval; +use codex_core::protocol::EventMsg; +use codex_core::protocol::Op; +use codex_core::protocol::SandboxPolicy; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::request_user_input::RequestUserInputAnswer; +use codex_protocol::request_user_input::RequestUserInputResponse; +use codex_protocol::user_input::UserInput; +use core_test_support::responses; +use core_test_support::responses::ResponsesRequest; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; +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 core_test_support::wait_for_event_match; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; + +fn call_output(req: &ResponsesRequest, call_id: &str) -> String { + let raw = req.function_call_output(call_id); + assert_eq!( + raw.get("call_id").and_then(Value::as_str), + Some(call_id), + "mismatched call_id in function_call_output" + ); + let (content_opt, _success) = match req.function_call_output_content_and_success(call_id) { + Some(values) => values, + None => panic!("function_call_output present"), + }; + match content_opt { + Some(content) => content, + None => panic!("function_call_output content present"), + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn request_user_input_round_trip_resolves_pending() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + + let builder = test_codex(); + let TestCodex { + codex, + cwd, + session_configured, + .. + } = builder + .with_config(|config| { + config.features.enable(Feature::CollaborationModes); + }) + .build(&server) + .await?; + + let call_id = "user-input-call"; + let request_args = json!({ + "questions": [{ + "id": "confirm_path", + "header": "Confirm", + "question": "Proceed with the plan?", + "options": [{ + "label": "Yes (Recommended)", + "description": "Continue the current plan." + }, { + "label": "No", + "description": "Stop and revisit the approach." + }] + }] + }) + .to_string(); + + let first_response = sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "request_user_input", &request_args), + ev_completed("resp-1"), + ]); + responses::mount_sse_once(&server, first_response).await; + + let second_response = sse(vec![ + ev_assistant_message("msg-1", "thanks"), + ev_completed("resp-2"), + ]); + let second_mock = responses::mount_sse_once(&server, second_response).await; + + let session_model = session_configured.model.clone(); + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "please confirm".into(), + text_elements: Vec::new(), + }], + 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, + collaboration_mode: None, + }) + .await?; + + let request = wait_for_event_match(&codex, |event| match event { + EventMsg::RequestUserInput(request) => Some(request.clone()), + _ => None, + }) + .await; + assert_eq!(request.call_id, call_id); + assert_eq!(request.questions.len(), 1); + + let mut answers = HashMap::new(); + answers.insert( + "confirm_path".to_string(), + RequestUserInputAnswer { + selected: vec!["yes".to_string()], + other: None, + }, + ); + let response = RequestUserInputResponse { answers }; + codex + .submit(Op::UserInputAnswer { + id: request.turn_id.clone(), + response, + }) + .await?; + + wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + + let req = second_mock.single_request(); + let output_text = call_output(&req, call_id); + let output_json: Value = serde_json::from_str(&output_text)?; + assert_eq!( + output_json, + json!({ + "answers": { + "confirm_path": { "selected": ["yes"], "other": Value::Null } + } + }) + ); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/resume.rs b/codex-rs/core/tests/suite/resume.rs index 8912870e5e..d0718a334d 100644 --- a/codex-rs/core/tests/suite/resume.rs +++ b/codex-rs/core/tests/suite/resume.rs @@ -35,10 +35,10 @@ async fn resume_includes_initial_messages_from_rollout_events() -> Result<()> { ]); mount_sse_once(&server, initial_sse).await; - let text_elements = vec![TextElement { - byte_range: ByteRange { start: 0, end: 6 }, - placeholder: Some("".into()), - }]; + let text_elements = vec![TextElement::new( + ByteRange { start: 0, end: 6 }, + Some("".into()), + )]; codex .submit(Op::UserInput { @@ -131,3 +131,78 @@ async fn resume_includes_initial_messages_from_reasoning_events() -> Result<()> Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn resume_switches_models_preserves_base_instructions() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let mut builder = test_codex().with_config(|config| { + config.model = Some("gpt-5.2".to_string()); + }); + let initial = builder.build(&server).await?; + let codex = Arc::clone(&initial.codex); + let home = initial.home.clone(); + let rollout_path = initial.session_configured.rollout_path.clone(); + + let initial_sse = sse(vec![ + ev_response_created("resp-initial"), + ev_assistant_message("msg-1", "Completed first turn"), + ev_completed("resp-initial"), + ]); + let initial_mock = mount_sse_once(&server, initial_sse).await; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "Record initial instructions".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + + let initial_body = initial_mock.single_request().body_json(); + let initial_instructions = initial_body + .get("instructions") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + + let resumed_sse = sse(vec![ + ev_response_created("resp-resume"), + ev_assistant_message("msg-2", "Resumed turn"), + ev_completed("resp-resume"), + ]); + let resumed_mock = mount_sse_once(&server, resumed_sse).await; + + let mut resume_builder = test_codex().with_config(|config| { + config.model = Some("gpt-5.2-codex".to_string()); + }); + let resumed = resume_builder.resume(&server, home, rollout_path).await?; + resumed + .codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "Resume with different model".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&resumed.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let resumed_body = resumed_mock.single_request().body_json(); + let resumed_instructions = resumed_body + .get("instructions") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + assert_eq!(resumed_instructions, initial_instructions); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index df0534bf40..8c3ecf7afe 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -28,7 +28,6 @@ fn resume_history( model: previous_model.to_string(), effort: config.model_reasoning_effort, summary: config.model_reasoning_summary, - base_instructions: None, user_instructions: None, developer_instructions: None, final_output_json_schema: None, diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index d182af8255..e5c980f681 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -515,7 +515,6 @@ async fn review_input_isolated_from_parent_history() { "payload": { "id": convo_id, "timestamp": "2024-01-01T00:00:00Z", - "instructions": null, "cwd": ".", "originator": "test_originator", "cli_version": "test_version", @@ -820,6 +819,7 @@ async fn review_uses_overridden_cwd_for_base_branch_merge_base() { model: None, effort: None, summary: None, + collaboration_mode: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 5abc392644..cba1ed6225 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -118,6 +118,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -256,6 +257,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -452,6 +454,7 @@ async fn stdio_image_completions_round_trip() -> anyhow::Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -596,6 +599,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -751,6 +755,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -938,6 +943,7 @@ async fn streamable_http_with_oauth_round_trip() -> anyhow::Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; diff --git a/codex-rs/core/tests/suite/rollout_list_find.rs b/codex-rs/core/tests/suite/rollout_list_find.rs index 518f26c562..a4213729bc 100644 --- a/codex-rs/core/tests/suite/rollout_list_find.rs +++ b/codex-rs/core/tests/suite/rollout_list_find.rs @@ -25,7 +25,6 @@ fn write_minimal_rollout_with_id(codex_home: &Path, id: Uuid) -> PathBuf { "payload": { "id": id, "timestamp": "2024-01-01T00:00:00Z", - "instructions": null, "cwd": ".", "originator": "test", "cli_version": "test", diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 74ff56e84e..443a41abab 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -80,6 +80,7 @@ async fn run_snapshot_command(command: &str) -> Result { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -157,6 +158,7 @@ async fn run_shell_command_snapshot(command: &str) -> Result { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -295,6 +297,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index eb6b4288d0..4c1c338bca 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -78,6 +78,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { model: session_model, effort: None, summary: codex_protocol::config_types::ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 75d707af53..d9bdf83ed1 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -28,7 +28,6 @@ use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use serde_json::Value; use serde_json::json; - fn call_output(req: &ResponsesRequest, call_id: &str) -> (String, Option) { let raw = req.function_call_output(call_id); assert_eq!( @@ -91,6 +90,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -158,6 +158,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -235,6 +236,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -324,6 +326,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -421,6 +424,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index 4abf85d7dc..05c02d4228 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -47,6 +47,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -363,6 +364,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 4ae095517c..bd4a9a3061 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -547,6 +547,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; diff --git a/codex-rs/core/tests/suite/turn_state.rs b/codex-rs/core/tests/suite/turn_state.rs new file mode 100644 index 0000000000..3198f07cd6 --- /dev/null +++ b/codex-rs/core/tests/suite/turn_state.rs @@ -0,0 +1,122 @@ +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use anyhow::Result; +use core_test_support::responses::WebSocketConnectionConfig; +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_reasoning_item; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::ev_shell_command_call; +use core_test_support::responses::mount_response_sequence; +use core_test_support::responses::sse; +use core_test_support::responses::sse_response; +use core_test_support::responses::start_mock_server; +use core_test_support::responses::start_websocket_server_with_headers; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::test_codex; +use pretty_assertions::assert_eq; + +const TURN_STATE_HEADER: &str = "x-codex-turn-state"; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_turn_state_persists_within_turn_and_resets_after() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let call_id = "shell-turn-state"; + + let first_response = sse(vec![ + ev_response_created("resp-1"), + ev_reasoning_item("rsn-1", &["thinking"], &[]), + ev_shell_command_call(call_id, "echo turn-state"), + ev_completed("resp-1"), + ]); + let second_response = sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]); + let third_response = sse(vec![ + ev_response_created("resp-3"), + ev_assistant_message("msg-2", "done"), + ev_completed("resp-3"), + ]); + + // First response sets turn_state; follow-up request in the same turn should echo it. + let responses = vec![ + sse_response(first_response).insert_header(TURN_STATE_HEADER, "ts-1"), + sse_response(second_response), + sse_response(third_response), + ]; + let request_log = mount_response_sequence(&server, responses).await; + + let test = test_codex().build(&server).await?; + test.submit_turn("run a shell command").await?; + test.submit_turn("second turn").await?; + + let requests = request_log.requests(); + assert_eq!(requests.len(), 3); + // Initial turn request has no header; follow-up has it; next turn clears it. + assert_eq!(requests[0].header(TURN_STATE_HEADER), None); + assert_eq!( + requests[1].header(TURN_STATE_HEADER), + Some("ts-1".to_string()) + ); + assert_eq!(requests[2].header(TURN_STATE_HEADER), None); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn websocket_turn_state_persists_within_turn_and_resets_after() -> Result<()> { + skip_if_no_network!(Ok(())); + + let call_id = "ws-shell-turn-state"; + // First connection delivers turn_state; second (same turn) must send it; third (new turn) must not. + let server = start_websocket_server_with_headers(vec![ + WebSocketConnectionConfig { + requests: vec![vec![ + ev_response_created("resp-1"), + ev_reasoning_item("rsn-1", &["thinking"], &[]), + ev_shell_command_call(call_id, "echo websocket"), + ev_done(), + ]], + response_headers: vec![(TURN_STATE_HEADER.to_string(), "ts-1".to_string())], + }, + WebSocketConnectionConfig { + requests: vec![vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]], + response_headers: Vec::new(), + }, + WebSocketConnectionConfig { + requests: vec![vec![ + ev_response_created("resp-3"), + ev_assistant_message("msg-2", "done"), + ev_completed("resp-3"), + ]], + response_headers: Vec::new(), + }, + ]) + .await; + + let mut builder = test_codex(); + let test = builder.build_with_websocket_server(&server).await?; + test.submit_turn("run the echo command").await?; + test.submit_turn("second turn").await?; + + let handshakes = server.handshakes(); + assert_eq!(handshakes.len(), 3); + assert_eq!(handshakes[0].header(TURN_STATE_HEADER), None); + assert_eq!( + handshakes[1].header(TURN_STATE_HEADER), + Some("ts-1".to_string()) + ); + assert_eq!(handshakes[2].header(TURN_STATE_HEADER), None); + + server.shutdown().await; + Ok(()) +} diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 3a6c728df0..c3816aefd3 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -209,6 +209,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -336,6 +337,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -412,6 +414,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -491,6 +494,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -582,6 +586,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -655,6 +660,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -729,6 +735,7 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -805,6 +812,7 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> let open_args = json!({ "cmd": "/bin/bash -i", "yield_time_ms": 200, + "tty": true, }); let stdin_call_id = "uexec-stdin-delta"; @@ -856,6 +864,7 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -905,6 +914,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( let open_args = json!({ "cmd": "sleep 3 && echo MARKER1 && sleep 3 && echo MARKER2", "yield_time_ms": 10, + "tty": true, }); // Poll stdin three times: first for no output, second after the first marker, @@ -990,6 +1000,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -1148,6 +1159,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -1244,6 +1256,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -1360,6 +1373,7 @@ async fn unified_exec_defaults_to_pipe() -> Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -1448,6 +1462,7 @@ async fn unified_exec_can_enable_tty() -> Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -1527,6 +1542,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -1656,6 +1672,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -1822,6 +1839,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<() model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -1897,6 +1915,7 @@ async fn unified_exec_closes_long_running_session_at_turn_end() -> Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -1966,6 +1985,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { let first_args = serde_json::json!({ "cmd": "/bin/cat", "yield_time_ms": 200, + "tty": true, }); let second_call_id = "uexec-stdin"; @@ -2016,6 +2036,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -2149,6 +2170,7 @@ PY model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; // This is a worst case scenario for the truncate logic. @@ -2261,6 +2283,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -2355,6 +2378,7 @@ PY model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -2435,6 +2459,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -2537,6 +2562,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -2630,6 +2656,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -2678,6 +2705,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { let keep_args = serde_json::json!({ "cmd": "/bin/cat", "yield_time_ms": 250, + "tty": true, }); let prune_call_id = "uexec-prune-target"; @@ -2685,6 +2713,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { let prune_args = serde_json::json!({ "cmd": "sleep 1", "yield_time_ms": 1_250, + "tty": true, }); let mut events = vec![ev_response_created("resp-prune-1")]; @@ -2761,6 +2790,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index ebbbfc478e..c6869323ff 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -88,6 +88,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -180,6 +181,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -311,6 +313,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -384,6 +387,7 @@ async fn view_image_tool_placeholder_for_non_image_files() -> anyhow::Result<()> model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -476,6 +480,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; @@ -557,6 +562,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> model: session_model, effort: None, summary: ReasoningSummary::Auto, + collaboration_mode: None, }) .await?; diff --git a/codex-rs/debug-client/src/client.rs b/codex-rs/debug-client/src/client.rs index 5267e48156..673a2a8fbc 100644 --- a/codex-rs/debug-client/src/client.rs +++ b/codex-rs/debug-client/src/client.rs @@ -171,6 +171,7 @@ impl AppServerClient { params: ThreadListParams { cursor, limit: None, + sort_key: None, model_providers: None, }, }; @@ -186,7 +187,7 @@ impl AppServerClient { thread_id: thread_id.to_string(), input: vec![UserInput::Text { text, - // Plain text conversion has no UI element ranges. + // Debug client sends plain text with no UI markup spans. text_elements: Vec::new(), }], ..Default::default() diff --git a/codex-rs/docs/codex_mcp_interface.md b/codex-rs/docs/codex_mcp_interface.md index 10f81e59b9..883c7321bc 100644 --- a/codex-rs/docs/codex_mcp_interface.md +++ b/codex-rs/docs/codex_mcp_interface.md @@ -20,6 +20,7 @@ At a glance: - Configuration and info - `getUserSavedConfig`, `setDefaultModel`, `getUserAgent`, `userInfo` - `model/list` → enumerate available models and reasoning options + - `collaborationMode/list` → enumerate collaboration mode presets (experimental) - Auth - `account/read`, `account/login/start`, `account/login/cancel`, `account/logout`, `account/rateLimits/read` - notifications: `account/login/completed`, `account/updated`, `account/rateLimits/updated` @@ -96,6 +97,12 @@ Each response yields: - `isDefault` – whether the model is recommended for most users - `nextCursor` – pass into the next request to continue paging (optional) +## Collaboration modes (experimental) + +Fetch the built-in collaboration mode presets with `collaborationMode/list`. This endpoint does not accept pagination and returns the full list in one response: + +- `data` – ordered list of collaboration mode presets + ## Event stream While a conversation runs, the server sends notifications: diff --git a/codex-rs/docs/protocol_v1.md b/codex-rs/docs/protocol_v1.md index 0e4e1ddde3..5133f6f75f 100644 --- a/codex-rs/docs/protocol_v1.md +++ b/codex-rs/docs/protocol_v1.md @@ -1,4 +1,4 @@ -Overview of Protocol Defined in [protocol.rs](../core/src/protocol.rs) and [agent.rs](../core/src/agent.rs). +Overview of Protocol defined in [protocol.rs](../protocol/src/protocol.rs) and [agent.rs](../core/src/agent.rs). The goal of this document is to define terminology used in the system and explain the expected behavior of the system. @@ -23,11 +23,11 @@ These are entities exit on the codex backend. The intent of this section is to e 3. `Task` - A `Task` is `Codex` executing work in response to user input. - `Session` has at most one `Task` running at a time. - - Receiving `Op::UserInput` starts a `Task` + - Receiving `Op::UserTurn` starts a `Task` (`Op::UserInput` is legacy) - Consists of a series of `Turn`s - The `Task` executes to until: - The `Model` completes the task and there is no output to feed into an additional `Turn` - - Additional `Op::UserInput` aborts the current task and starts a new one + - Additional user-turn input aborts the current task and starts a new one - UI interrupts with `Op::Interrupt` - Fatal errors are encountered, eg. `Model` connection exceeding retry limits - Blocked by user approval (executing a command or patch) @@ -42,7 +42,7 @@ These are entities exit on the codex backend. The intent of this section is to e The term "UI" is used to refer to the application driving `Codex`. This may be the CLI / TUI chat-like interface that users operate, or it may be a GUI interface like a VSCode extension. The UI is external to `Codex`, as `Codex` is intended to be operated by arbitrary UI implementations. -When a `Turn` completes, the `response_id` from the `Model`'s final `response.completed` message is stored in the `Session` state to resume the thread given the next `Op::UserInput`. The `response_id` is also returned in the `EventMsg::TurnComplete` to the UI, which can be used to fork the thread from an earlier point by providing it in the `Op::UserInput`. +When a `Turn` completes, the `response_id` from the `Model`'s final `response.completed` message is stored in the `Session` state to resume the thread given the next user turn. The `response_id` is also returned in the `EventMsg::TurnComplete` to the UI, which can be used to fork the thread from an earlier point by providing it in a future user turn. Since only 1 `Task` can be run at a time, for parallel tasks it is recommended that a single `Codex` be run for each thread of work. @@ -57,21 +57,24 @@ Since only 1 `Task` can be run at a time, for parallel tasks it is recommended t - This enum is `non_exhaustive`; variants can be added at future dates - `Event` - These are messages sent on the `EQ` (`Codex` -> UI) - - Each `Event` has a non-unique ID, matching the `sub_id` from the `Op::UserInput` that started the current task. + - Each `Event` has a non-unique ID, matching the `sub_id` from the user-turn op that started the current task. - `EventMsg` refers to the enum of all possible `Event` payloads - This enum is `non_exhaustive`; variants can be added at future dates - It should be expected that new `EventMsg` variants will be added over time to expose more detailed information about the model's actions. -For complete documentation of the `Op` and `EventMsg` variants, refer to [protocol.rs](../core/src/protocol.rs). Some example payload types: +For complete documentation of the `Op` and `EventMsg` variants, refer to [protocol.rs](../protocol/src/protocol.rs). Some example payload types: - `Op` - - `Op::UserInput` – Any input from the user to kick off a `Turn` + - `Op::UserTurn` – Any input from the user to kick off a `Turn` + - `Op::UserInput` – Legacy form of user input - `Op::Interrupt` – Interrupts a running turn - `Op::ExecApproval` – Approve or deny code execution + - `Op::UserInputAnswer` – Provide answers for a `request_user_input` tool call - `Op::ListSkills` – Request skills for one or more cwd values (optionally `force_reload`) - `EventMsg` - `EventMsg::AgentMessage` – Messages from the `Model` - `EventMsg::ExecApprovalRequest` – Request approval from user to execute a command + - `EventMsg::RequestUserInput` – Request user input for a tool call - `EventMsg::TurnComplete` – A turn completed successfully - `EventMsg::Error` – A turn stopped with an error - `EventMsg::Warning` – A non-fatal warning that the client should surface to the user @@ -112,7 +115,7 @@ sequenceDiagram user->>codex: Op::ConfigureSession codex-->>session: create session codex->>user: Event::SessionConfigured - user->>session: Op::UserInput + user->>session: Op::UserTurn session-->>+task: start task task->>user: Event::TurnStarted task->>agent: prompt @@ -150,7 +153,7 @@ sequenceDiagram box Rest API participant agent as Model end - user->>session: Op::UserInput + user->>session: Op::UserTurn session-->>+task1: start task task1->>user: Event::TurnStarted task1->>agent: prompt @@ -162,7 +165,7 @@ sequenceDiagram task1->>task1: exec (auto-approved) user->>task1: Op::Interrupt task1->>-user: Event::Error("interrupted") - user->>session: Op::UserInput w/ last_response_id + user->>session: Op::UserTurn w/ response bookmark session-->>+task2: start task task2->>user: Event::TurnStarted task2->>agent: prompt + Task1 last_response_id diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index e12e869394..3d2001ae61 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -119,6 +119,10 @@ pub struct ResumeArgs { #[arg(long = "last", default_value_t = false)] pub last: bool, + /// Show all sessions (disables cwd filtering). + #[arg(long = "all", default_value_t = false)] + pub all: bool, + /// Optional image(s) to attach to the prompt sent after resuming. #[arg( long = "image", diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index 42a83e44d1..c4a5c27d9c 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -606,7 +606,8 @@ impl EventProcessor for EventProcessorWithHumanOutput { | EventMsg::SkillsUpdateAvailable | EventMsg::UndoCompleted(_) | EventMsg::UndoStarted(_) - | EventMsg::ThreadRolledBack(_) => {} + | EventMsg::ThreadRolledBack(_) + | EventMsg::RequestUserInput(_) => {} } CodexStatus::Running } diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 966dc65346..a7629a107f 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -360,6 +360,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any .collect(); items.push(UserInput::Text { text: prompt_text.clone(), + // CLI input doesn't track UI element ranges, so none are available here. text_elements: Vec::new(), }); let output_schema = load_output_schema(output_schema_path.clone()); @@ -379,6 +380,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any .collect(); items.push(UserInput::Text { text: prompt_text.clone(), + // CLI input doesn't track UI element ranges, so none are available here. text_elements: Vec::new(), }); let output_schema = load_output_schema(output_schema_path); @@ -452,6 +454,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any effort: default_effort, summary: default_summary, final_output_json_schema: output_schema, + collaboration_mode: None, }) .await?; info!("Sent prompt with event ID: {task_id}"); @@ -507,17 +510,24 @@ async fn resolve_resume_path( ) -> anyhow::Result> { if args.last { let default_provider_filter = vec![config.model_provider_id.clone()]; - match codex_core::RolloutRecorder::list_threads( + let filter_cwd = if args.all { + None + } else { + Some(config.cwd.as_path()) + }; + match codex_core::RolloutRecorder::find_latest_thread_path( &config.codex_home, 1, None, + codex_core::ThreadSortKey::UpdatedAt, &[], Some(default_provider_filter.as_slice()), &config.model_provider_id, + filter_cwd, ) .await { - Ok(page) => Ok(page.items.first().map(|it| it.path.clone())), + Ok(path) => Ok(path), Err(e) => { error!("Error listing threads: {e}"); Ok(None) diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index e60fcba8e8..e0b2509667 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -75,6 +75,7 @@ fn session_configured_produces_thread_started_event() { "e1", EventMsg::SessionConfigured(SessionConfiguredEvent { session_id, + forked_from_id: None, model: "codex-mini-latest".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, diff --git a/codex-rs/exec/tests/suite/resume.rs b/codex-rs/exec/tests/suite/resume.rs index 32913983e6..ec7fec0831 100644 --- a/codex-rs/exec/tests/suite/resume.rs +++ b/codex-rs/exec/tests/suite/resume.rs @@ -5,6 +5,7 @@ use core_test_support::test_codex_exec::test_codex_exec; use pretty_assertions::assert_eq; use serde_json::Value; use std::string::ToString; +use tempfile::TempDir; use uuid::Uuid; use walkdir::WalkDir; @@ -219,6 +220,90 @@ fn exec_resume_last_accepts_prompt_after_flag_in_json_mode() -> anyhow::Result<( Ok(()) } +#[test] +fn exec_resume_last_respects_cwd_filter_and_all_flag() -> anyhow::Result<()> { + let test = test_codex_exec(); + let fixture = exec_fixture()?; + + let dir_a = TempDir::new()?; + let dir_b = TempDir::new()?; + + let marker_a = format!("resume-cwd-a-{}", Uuid::new_v4()); + let prompt_a = format!("echo {marker_a}"); + test.cmd() + .env("CODEX_RS_SSE_FIXTURE", &fixture) + .env("OPENAI_BASE_URL", "http://unused.local") + .arg("--skip-git-repo-check") + .arg("-C") + .arg(dir_a.path()) + .arg(&prompt_a) + .assert() + .success(); + + let marker_b = format!("resume-cwd-b-{}", Uuid::new_v4()); + let prompt_b = format!("echo {marker_b}"); + test.cmd() + .env("CODEX_RS_SSE_FIXTURE", &fixture) + .env("OPENAI_BASE_URL", "http://unused.local") + .arg("--skip-git-repo-check") + .arg("-C") + .arg(dir_b.path()) + .arg(&prompt_b) + .assert() + .success(); + + let sessions_dir = test.home_path().join("sessions"); + let path_a = find_session_file_containing_marker(&sessions_dir, &marker_a) + .expect("no session file found for marker_a"); + let path_b = find_session_file_containing_marker(&sessions_dir, &marker_b) + .expect("no session file found for marker_b"); + + let marker_b2 = format!("resume-cwd-b-2-{}", Uuid::new_v4()); + let prompt_b2 = format!("echo {marker_b2}"); + test.cmd() + .env("CODEX_RS_SSE_FIXTURE", &fixture) + .env("OPENAI_BASE_URL", "http://unused.local") + .arg("--skip-git-repo-check") + .arg("-C") + .arg(dir_a.path()) + .arg("resume") + .arg("--last") + .arg("--all") + .arg(&prompt_b2) + .assert() + .success(); + + let resumed_path_all = find_session_file_containing_marker(&sessions_dir, &marker_b2) + .expect("no resumed session file containing marker_b2"); + assert_eq!( + resumed_path_all, path_b, + "resume --last --all should pick newest session" + ); + + let marker_a2 = format!("resume-cwd-a-2-{}", Uuid::new_v4()); + let prompt_a2 = format!("echo {marker_a2}"); + test.cmd() + .env("CODEX_RS_SSE_FIXTURE", &fixture) + .env("OPENAI_BASE_URL", "http://unused.local") + .arg("--skip-git-repo-check") + .arg("-C") + .arg(dir_a.path()) + .arg("resume") + .arg("--last") + .arg(&prompt_a2) + .assert() + .success(); + + let resumed_path_cwd = find_session_file_containing_marker(&sessions_dir, &marker_a2) + .expect("no resumed session file containing marker_a2"); + assert_eq!( + resumed_path_cwd, path_a, + "resume --last should prefer sessions from the same cwd" + ); + + Ok(()) +} + #[test] fn exec_resume_accepts_global_flags_after_subcommand() -> anyhow::Result<()> { let test = test_codex_exec(); diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 531bf90d23..b400b4cc79 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -122,6 +122,7 @@ pub async fn run_codex_tool_session( op: Op::UserInput { items: vec![UserInput::Text { text: initial_prompt.clone(), + // MCP tool prompts are plain text with no UI element ranges. text_elements: Vec::new(), }], final_output_json_schema: None, @@ -167,7 +168,7 @@ pub async fn run_codex_tool_session_reply( .submit(Op::UserInput { items: vec![UserInput::Text { text: prompt, - // Plain text conversion has no UI element ranges. + // MCP tool prompts are plain text with no UI element ranges. text_elements: Vec::new(), }], final_output_json_schema: None, @@ -358,6 +359,7 @@ async fn run_codex_tool_session_inner( | EventMsg::UndoStarted(_) | EventMsg::UndoCompleted(_) | EventMsg::ExitedReviewMode(_) + | EventMsg::RequestUserInput(_) | EventMsg::ContextCompacted(_) | EventMsg::ThreadRolledBack(_) | EventMsg::CollabAgentSpawnBegin(_) diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs index 0b05bc3609..b7c66592ff 100644 --- a/codex-rs/mcp-server/src/outgoing_message.rs +++ b/codex-rs/mcp-server/src/outgoing_message.rs @@ -257,6 +257,7 @@ mod tests { id: "1".to_string(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: thread_id, + forked_from_id: None, model: "gpt-4o".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -296,6 +297,7 @@ mod tests { let rollout_file = NamedTempFile::new()?; let session_configured_event = SessionConfiguredEvent { session_id: conversation_id, + forked_from_id: None, model: "gpt-4o".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -359,6 +361,7 @@ mod tests { let rollout_file = NamedTempFile::new()?; let session_configured_event = SessionConfiguredEvent { session_id: thread_id, + forked_from_id: None, model: "gpt-4o".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, diff --git a/codex-rs/mcp-server/tests/common/mcp_process.rs b/codex-rs/mcp-server/tests/common/mcp_process.rs index 7e447533d0..9a3f076fb1 100644 --- a/codex-rs/mcp-server/tests/common/mcp_process.rs +++ b/codex-rs/mcp-server/tests/common/mcp_process.rs @@ -137,8 +137,10 @@ impl McpProcess { let initialized = self.read_jsonrpc_message().await?; let os_info = os_info::get(); + let build_version = env!("CARGO_PKG_VERSION"); + let originator = codex_core::default_client::originator().value; let user_agent = format!( - "codex_cli_rs/0.0.0 ({} {}; {}) {} (elicitation test; 0.0.0)", + "{originator}/{build_version} ({} {}; {}) {} (elicitation test; 0.0.0)", os_info.os_type(), os_info.version(), os_info.architecture().unwrap_or("unknown"), diff --git a/codex-rs/otel/Cargo.toml b/codex-rs/otel/Cargo.toml index eb19ec7df7..41c1df64b7 100644 --- a/codex-rs/otel/Cargo.toml +++ b/codex-rs/otel/Cargo.toml @@ -43,7 +43,7 @@ opentelemetry-otlp = { workspace = true, features = [ opentelemetry-semantic-conventions = { workspace = true } opentelemetry_sdk = { workspace = true, features = ["logs", "metrics", "rt-tokio", "testing", "trace"] } http = { workspace = true } -reqwest = { workspace = true, features = ["blocking", "rustls-tls"] } +reqwest = { workspace = true, features = ["blocking", "rustls-tls-webpki-roots", "rustls-tls-native-roots"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } strum_macros = { workspace = true } diff --git a/codex-rs/otel/src/lib.rs b/codex-rs/otel/src/lib.rs index 868a977735..32540d1071 100644 --- a/codex-rs/otel/src/lib.rs +++ b/codex-rs/otel/src/lib.rs @@ -9,7 +9,7 @@ use crate::metrics::MetricsClient; use crate::metrics::MetricsConfig; use crate::metrics::MetricsError; use crate::metrics::Result as MetricsResult; -use crate::metrics::timer::Timer; +pub use crate::metrics::timer::Timer; use crate::metrics::validation::validate_tag_key; use crate::metrics::validation::validate_tag_value; use crate::otel_provider::OtelProvider; @@ -170,3 +170,11 @@ impl OtelManager { Ok(()) } } + +/// Start a metrics timer using the globally installed metrics client. +pub fn start_global_timer(name: &str, tags: &[(&str, &str)]) -> MetricsResult { + let Some(metrics) = crate::metrics::global() else { + return Err(MetricsError::ExporterDisabled); + }; + metrics.start_timer(name, tags) +} diff --git a/codex-rs/otel/src/metrics/timer.rs b/codex-rs/otel/src/metrics/timer.rs index b1624fda16..86712f6a0e 100644 --- a/codex-rs/otel/src/metrics/timer.rs +++ b/codex-rs/otel/src/metrics/timer.rs @@ -2,6 +2,7 @@ use crate::metrics::MetricsClient; use crate::metrics::error::Result; use std::time::Instant; +#[derive(Debug)] pub struct Timer { name: String, tags: Vec<(String, String)>, @@ -11,7 +12,7 @@ pub struct Timer { impl Drop for Timer { fn drop(&mut self) { - if let Err(e) = self.record() { + if let Err(e) = self.record(&[]) { tracing::error!("metrics client error: {}", e); } } @@ -30,12 +31,10 @@ impl Timer { } } - pub fn record(&self) -> Result<()> { - let tags = self - .tags - .iter() - .map(|(k, v)| (k.as_str(), v.as_str())) - .collect::>(); + pub fn record(&self, additional_tags: &[(&str, &str)]) -> Result<()> { + let mut tags = Vec::with_capacity(self.tags.len() + additional_tags.len()); + tags.extend(additional_tags); + tags.extend(self.tags.iter().map(|(k, v)| (k.as_str(), v.as_str()))); self.client .record_duration(&self.name, self.start_time.elapsed(), &tags) } diff --git a/codex-rs/otel/src/traces/otel_manager.rs b/codex-rs/otel/src/traces/otel_manager.rs index 2b490bbb37..0847fd882a 100644 --- a/codex-rs/otel/src/traces/otel_manager.rs +++ b/codex-rs/otel/src/traces/otel_manager.rs @@ -446,6 +446,11 @@ impl OtelManager { 1, &[("tool", tool_name), ("success", success_str)], ); + self.record_duration( + "codex.tool.call.duration_ms", + duration, + &[("tool", tool_name), ("success", success_str)], + ); tracing::event!( tracing::Level::INFO, event.name = "codex.tool_result", @@ -479,6 +484,7 @@ impl OtelManager { ResponseEvent::ReasoningSummaryPartAdded { .. } => { "reasoning_summary_part_added".into() } + ResponseEvent::ServerReasoningIncluded(_) => "server_reasoning_included".into(), ResponseEvent::RateLimits(_) => "rate_limits".into(), ResponseEvent::ModelsEtag(_) => "models_etag".into(), } diff --git a/codex-rs/protocol/BUILD.bazel b/codex-rs/protocol/BUILD.bazel index 2c00d076bc..e47bc8c16b 100644 --- a/codex-rs/protocol/BUILD.bazel +++ b/codex-rs/protocol/BUILD.bazel @@ -3,5 +3,5 @@ load("//:defs.bzl", "codex_rust_crate") codex_rust_crate( name = "protocol", crate_name = "codex_protocol", - compile_data = glob(["src/prompts/permissions/**/*.md"]), + compile_data = glob(["src/prompts/**/*.md"]), ) diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index 3979bc3810..868217b3b5 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -4,6 +4,8 @@ use serde::Serialize; use strum_macros::Display; use ts_rs::TS; +use crate::openai_models::ReasoningEffort; + /// 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 @@ -127,3 +129,72 @@ pub enum AltScreenMode { /// Never use alternate screen (inline mode only). Never, } + +/// Collaboration mode for a Codex session. +#[derive(Clone, PartialEq, Eq, Hash, Debug, Serialize, Deserialize, JsonSchema, TS)] +#[serde(tag = "mode", rename_all = "lowercase")] +pub enum CollaborationMode { + Plan(Settings), + PairProgramming(Settings), + Execute(Settings), + Custom(Settings), +} + +impl CollaborationMode { + /// Returns a reference to the settings, regardless of variant. + fn settings(&self) -> &Settings { + match self { + CollaborationMode::Plan(settings) + | CollaborationMode::PairProgramming(settings) + | CollaborationMode::Execute(settings) + | CollaborationMode::Custom(settings) => settings, + } + } + + pub fn model(&self) -> &str { + self.settings().model.as_str() + } + + pub fn reasoning_effort(&self) -> Option { + self.settings().reasoning_effort + } + + /// Updates the collaboration mode with new model and/or effort values. + /// + /// - `model`: `Some(s)` to update the model, `None` to keep the current model + /// - `effort`: `Some(Some(e))` to set effort to `e`, `Some(None)` to clear effort, `None` to keep current effort + /// - `developer_instructions`: `Some(s)` to update developer instructions, `None` to keep current + /// + /// Returns a new `CollaborationMode` with updated values, preserving the variant. + pub fn with_updates( + &self, + model: Option, + effort: Option>, + developer_instructions: Option, + ) -> Self { + let settings = self.settings(); + let updated_settings = Settings { + model: model.unwrap_or_else(|| settings.model.clone()), + reasoning_effort: effort.unwrap_or(settings.reasoning_effort), + developer_instructions: developer_instructions + .or_else(|| settings.developer_instructions.clone()), + }; + + match self { + CollaborationMode::Plan(_) => CollaborationMode::Plan(updated_settings), + CollaborationMode::PairProgramming(_) => { + CollaborationMode::PairProgramming(updated_settings) + } + CollaborationMode::Execute(_) => CollaborationMode::Execute(updated_settings), + CollaborationMode::Custom(_) => CollaborationMode::Custom(updated_settings), + } + } +} + +/// Settings for a collaboration mode. +#[derive(Clone, PartialEq, Eq, Hash, Debug, Serialize, Deserialize, JsonSchema, TS)] +pub struct Settings { + pub model: String, + pub reasoning_effort: Option, + pub developer_instructions: Option, +} diff --git a/codex-rs/protocol/src/items.rs b/codex-rs/protocol/src/items.rs index cb04d427b9..9276a759cc 100644 --- a/codex-rs/protocol/src/items.rs +++ b/codex-rs/protocol/src/items.rs @@ -97,13 +97,14 @@ impl UserMessageItem { // Text element ranges are relative to each text chunk; offset them so they align // with the concatenated message returned by `message()`. for elem in text_elements { - out.push(TextElement { - byte_range: ByteRange { - start: offset + elem.byte_range.start, - end: offset + elem.byte_range.end, - }, - placeholder: elem.placeholder.clone(), - }); + let byte_range = ByteRange { + start: offset + elem.byte_range.start, + end: offset + elem.byte_range.end, + }; + out.push(TextElement::new( + byte_range, + elem.placeholder(text).map(str::to_string), + )); } offset += text.len(); } diff --git a/codex-rs/protocol/src/lib.rs b/codex-rs/protocol/src/lib.rs index 513743c97f..69d09da089 100644 --- a/codex-rs/protocol/src/lib.rs +++ b/codex-rs/protocol/src/lib.rs @@ -1,7 +1,5 @@ pub mod account; mod thread_id; -#[allow(deprecated)] -pub use thread_id::ConversationId; pub use thread_id::ThreadId; pub mod approvals; pub mod config_types; @@ -14,4 +12,5 @@ pub mod openai_models; pub mod parse_command; pub mod plan_tool; pub mod protocol; +pub mod request_user_input; pub mod user_input; diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index d2c9ddd564..1b22fb925f 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -10,8 +10,11 @@ use serde::Serialize; use serde::ser::Serializer; use ts_rs::TS; +use crate::config_types::CollaborationMode; use crate::config_types::SandboxMode; use crate::protocol::AskForApproval; +use crate::protocol::COLLABORATION_MODE_CLOSE_TAG; +use crate::protocol::COLLABORATION_MODE_OPEN_TAG; use crate::protocol::NetworkAccess; use crate::protocol::SandboxPolicy; use crate::protocol::WritableRoot; @@ -164,6 +167,23 @@ pub enum ResponseItem { Other, } +pub const BASE_INSTRUCTIONS_DEFAULT: &str = include_str!("prompts/base_instructions/default.md"); + +/// Base instructions for the model in a thread. Corresponds to the `instructions` field in the ResponsesAPI. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] +#[serde(rename = "base_instructions", rename_all = "snake_case")] +pub struct BaseInstructions { + pub text: String, +} + +impl Default for BaseInstructions { + fn default() -> Self { + Self { + text: BASE_INSTRUCTIONS_DEFAULT.to_string(), + } + } +} + /// Developer-provided guidance that is injected into a turn as a developer role /// message. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] @@ -230,6 +250,25 @@ impl DeveloperInstructions { ) } + /// Returns developer instructions from a collaboration mode if they exist and are non-empty. + pub fn from_collaboration_mode(collaboration_mode: &CollaborationMode) -> Option { + let settings = match collaboration_mode { + CollaborationMode::Plan(settings) + | CollaborationMode::PairProgramming(settings) + | CollaborationMode::Execute(settings) + | CollaborationMode::Custom(settings) => settings, + }; + settings + .developer_instructions + .as_ref() + .filter(|instructions| !instructions.is_empty()) + .map(|instructions| { + DeveloperInstructions::new(format!( + "{COLLABORATION_MODE_OPEN_TAG}{instructions}{COLLABORATION_MODE_CLOSE_TAG}" + )) + }) + } + fn from_permissions_with_network( sandbox_mode: SandboxMode, network_access: NetworkAccess, diff --git a/codex-rs/protocol/src/prompts/base_instructions/default.md b/codex-rs/protocol/src/prompts/base_instructions/default.md new file mode 100644 index 0000000000..4886c7ef44 --- /dev/null +++ b/codex-rs/protocol/src/prompts/base_instructions/default.md @@ -0,0 +1,275 @@ +You are a coding agent running in the Codex CLI, a terminal-based coding assistant. Codex CLI is an open source project led by OpenAI. You are expected to be precise, safe, and helpful. + +Your capabilities: + +- Receive user prompts and other context provided by the harness, such as files in the workspace. +- Communicate with the user by streaming thinking & responses, and by making & updating plans. +- Emit function calls to run terminal commands and apply patches. Depending on how this specific run is configured, you can request that these function calls be escalated to the user for approval before running. More on this in the "Sandbox and approvals" section. + +Within this context, Codex refers to the open-source agentic coding interface (not the old Codex language model built by OpenAI). + +# How you work + +## Personality + +Your default personality and tone is concise, direct, and friendly. You communicate efficiently, always keeping the user clearly informed about ongoing actions without unnecessary detail. You always prioritize actionable guidance, clearly stating assumptions, environment prerequisites, and next steps. Unless explicitly asked, you avoid excessively verbose explanations about your work. + +# AGENTS.md spec +- Repos often contain AGENTS.md files. These files can appear anywhere within the repository. +- These files are a way for humans to give you (the agent) instructions or tips for working within the container. +- Some examples might be: coding conventions, info about how code is organized, or instructions for how to run or test code. +- Instructions in AGENTS.md files: + - The scope of an AGENTS.md file is the entire directory tree rooted at the folder that contains it. + - For every file you touch in the final patch, you must obey instructions in any AGENTS.md file whose scope includes that file. + - Instructions about code style, structure, naming, etc. apply only to code within the AGENTS.md file's scope, unless the file states otherwise. + - More-deeply-nested AGENTS.md files take precedence in the case of conflicting instructions. + - Direct system/developer/user instructions (as part of a prompt) take precedence over AGENTS.md instructions. +- The contents of the AGENTS.md file at the root of the repo and any directories from the CWD up to the root are included with the developer message and don't need to be re-read. When working in a subdirectory of CWD, or a directory outside the CWD, check for any AGENTS.md files that may be applicable. + +## Responsiveness + +### Preamble messages + +Before making tool calls, send a brief preamble to the user explaining what you’re about to do. When sending preamble messages, follow these principles and examples: + +- **Logically group related actions**: if you’re about to run several related commands, describe them together in one preamble rather than sending a separate note for each. +- **Keep it concise**: be no more than 1-2 sentences, focused on immediate, tangible next steps. (8–12 words for quick updates). +- **Build on prior context**: if this is not your first tool call, use the preamble message to connect the dots with what’s been done so far and create a sense of momentum and clarity for the user to understand your next actions. +- **Keep your tone light, friendly and curious**: add small touches of personality in preambles feel collaborative and engaging. +- **Exception**: Avoid adding a preamble for every trivial read (e.g., `cat` a single file) unless it’s part of a larger grouped action. + +**Examples:** + +- “I’ve explored the repo; now checking the API route definitions.” +- “Next, I’ll patch the config and update the related tests.” +- “I’m about to scaffold the CLI commands and helper functions.” +- “Ok cool, so I’ve wrapped my head around the repo. Now digging into the API routes.” +- “Config’s looking tidy. Next up is patching helpers to keep things in sync.” +- “Finished poking at the DB gateway. I will now chase down error handling.” +- “Alright, build pipeline order is interesting. Checking how it reports failures.” +- “Spotted a clever caching util; now hunting where it gets used.” + +## Planning + +You have access to an `update_plan` tool which tracks steps and progress and renders them to the user. Using the tool helps demonstrate that you've understood the task and convey how you're approaching it. Plans can help to make complex, ambiguous, or multi-phase work clearer and more collaborative for the user. A good plan should break the task into meaningful, logically ordered steps that are easy to verify as you go. + +Note that plans are not for padding out simple work with filler steps or stating the obvious. The content of your plan should not involve doing anything that you aren't capable of doing (i.e. don't try to test things that you can't test). Do not use plans for simple or single-step queries that you can just do or answer immediately. + +Do not repeat the full contents of the plan after an `update_plan` call — the harness already displays it. Instead, summarize the change made and highlight any important context or next step. + +Before running a command, consider whether or not you have completed the previous step, and make sure to mark it as completed before moving on to the next step. It may be the case that you complete all steps in your plan after a single pass of implementation. If this is the case, you can simply mark all the planned steps as completed. Sometimes, you may need to change plans in the middle of a task: call `update_plan` with the updated plan and make sure to provide an `explanation` of the rationale when doing so. + +Use a plan when: + +- The task is non-trivial and will require multiple actions over a long time horizon. +- There are logical phases or dependencies where sequencing matters. +- The work has ambiguity that benefits from outlining high-level goals. +- You want intermediate checkpoints for feedback and validation. +- When the user asked you to do more than one thing in a single prompt +- The user has asked you to use the plan tool (aka "TODOs") +- You generate additional steps while working, and plan to do them before yielding to the user + +### Examples + +**High-quality plans** + +Example 1: + +1. Add CLI entry with file args +2. Parse Markdown via CommonMark library +3. Apply semantic HTML template +4. Handle code blocks, images, links +5. Add error handling for invalid files + +Example 2: + +1. Define CSS variables for colors +2. Add toggle with localStorage state +3. Refactor components to use variables +4. Verify all views for readability +5. Add smooth theme-change transition + +Example 3: + +1. Set up Node.js + WebSocket server +2. Add join/leave broadcast events +3. Implement messaging with timestamps +4. Add usernames + mention highlighting +5. Persist messages in lightweight DB +6. Add typing indicators + unread count + +**Low-quality plans** + +Example 1: + +1. Create CLI tool +2. Add Markdown parser +3. Convert to HTML + +Example 2: + +1. Add dark mode toggle +2. Save preference +3. Make styles look good + +Example 3: + +1. Create single-file HTML game +2. Run quick sanity check +3. Summarize usage instructions + +If you need to write a plan, only write high quality plans, not low quality ones. + +## Task execution + +You are a coding agent. Please keep going until the query is completely resolved, before ending your turn and yielding back to the user. Only terminate your turn when you are sure that the problem is solved. Autonomously resolve the query to the best of your ability, using the tools available to you, before coming back to the user. Do NOT guess or make up an answer. + +You MUST adhere to the following criteria when solving queries: + +- Working on the repo(s) in the current environment is allowed, even if they are proprietary. +- Analyzing code for vulnerabilities is allowed. +- Showing user code and tool call details is allowed. +- Use the `apply_patch` tool to edit files (NEVER try `applypatch` or `apply-patch`, only `apply_patch`): {"command":["apply_patch","*** Begin Patch\\n*** Update File: path/to/file.py\\n@@ def example():\\n- pass\\n+ return 123\\n*** End Patch"]} + +If completing the user's task requires writing or modifying files, your code and final answer should follow these coding guidelines, though user instructions (i.e. AGENTS.md) may override these guidelines: + +- Fix the problem at the root cause rather than applying surface-level patches, when possible. +- Avoid unneeded complexity in your solution. +- Do not attempt to fix unrelated bugs or broken tests. It is not your responsibility to fix them. (You may mention them to the user in your final message though.) +- Update documentation as necessary. +- Keep changes consistent with the style of the existing codebase. Changes should be minimal and focused on the task. +- Use `git log` and `git blame` to search the history of the codebase if additional context is required. +- NEVER add copyright or license headers unless specifically requested. +- Do not waste tokens by re-reading files after calling `apply_patch` on them. The tool call will fail if it didn't work. The same goes for making folders, deleting folders, etc. +- Do not `git commit` your changes or create new git branches unless explicitly requested. +- Do not add inline comments within code unless explicitly requested. +- 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. + +## 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. + +When testing, your philosophy should be to start as specific as possible to the code you changed so that you can catch issues efficiently, then make your way to broader tests as you build confidence. If there's no test for the code you changed, and if the adjacent patterns in the codebases show that there's a logical place for you to add a test, you may do so. However, do not add tests to codebases with no tests. + +Similarly, once you're confident in correctness, you can suggest or use formatting commands to ensure that your code is well formatted. If there are issues you can iterate up to 3 times to get formatting right, but if you still can't manage it's better to save the user time and present them a correct solution where you call out the formatting in your final message. If the codebase does not have a formatter configured, do not add one. + +For all of testing, running, building, and formatting, do not attempt to fix unrelated bugs. It is not your responsibility to fix them. (You may mention them to the user in your final message though.) + +Be mindful of whether to run validation commands proactively. In the absence of behavioral guidance: + +- When running in non-interactive approval modes like **never** or **on-failure**, proactively run tests, lint and do whatever you need to ensure you've completed the task. +- When working in interactive approval modes like **untrusted**, or **on-request**, hold off on running tests or lint commands until the user is ready for you to finalize your output, because these commands take time to run and slow down iteration. Instead suggest what you want to do next, and let the user confirm first. +- When working on test-related tasks, such as adding tests, fixing tests, or reproducing a bug to verify behavior, you may proactively run tests regardless of approval mode. Use your judgement to decide whether this is a test-related task. + +## Ambition vs. precision + +For tasks that have no prior context (i.e. the user is starting something brand new), you should feel free to be ambitious and demonstrate creativity with your implementation. + +If you're operating in an existing codebase, you should make sure you do exactly what the user asks with surgical precision. Treat the surrounding codebase with respect, and don't overstep (i.e. changing filenames or variables unnecessarily). You should balance being sufficiently ambitious and proactive when completing tasks of this nature. + +You should use judicious initiative to decide on the right level of detail and complexity to deliver based on the user's needs. This means showing good judgment that you're capable of doing the right extras without gold-plating. This might be demonstrated by high-value, creative touches when scope of the task is vague; while being surgical and targeted when scope is tightly specified. + +## Sharing progress updates + +For especially longer tasks that you work on (i.e. requiring many tool calls, or a plan with multiple steps), you should provide progress updates back to the user at reasonable intervals. These updates should be structured as a concise sentence or two (no more than 8-10 words long) recapping progress so far in plain language: this update demonstrates your understanding of what needs to be done, progress so far (i.e. files explores, subtasks complete), and where you're going next. + +Before doing large chunks of work that may incur latency as experienced by the user (i.e. writing a new file), you should send a concise message to the user with an update indicating what you're about to do to ensure they know what you're spending time on. Don't start editing or writing large files before informing the user what you are doing and why. + +The messages you send before tool calls should describe what is immediately about to be done next in very concise language. If there was previous work done, this preamble message should also include a note about the work done so far to bring the user along. + +## Presenting your work and final message + +Your final message should read naturally, like an update from a concise teammate. For casual conversation, brainstorming tasks, or quick questions from the user, respond in a friendly, conversational tone. You should ask questions, suggest ideas, and adapt to the user’s style. If you've finished a large amount of work, when describing what you've done to the user, you should follow the final answer formatting guidelines to communicate substantive changes. You don't need to add structured formatting for one-word answers, greetings, or purely conversational exchanges. + +You can skip heavy formatting for single, simple actions or confirmations. In these cases, respond in plain sentences with any relevant next step or quick option. Reserve multi-section structured responses for results that need grouping or explanation. + +The user is working on the same computer as you, and has access to your work. As such there's no need to show the full contents of large files you have already written unless the user explicitly asks for them. Similarly, if you've created or modified files using `apply_patch`, there's no need to tell users to "save the file" or "copy the code into a file"—just reference the file path. + +If there's something that you think you could help with as a logical next step, concisely ask the user if they want you to do so. Good examples of this are running tests, committing changes, or building out the next logical component. If there’s something that you couldn't do (even with approval) but that the user might want to do (such as verifying changes by running the app), include those instructions succinctly. + +Brevity is very important as a default. You should be very concise (i.e. no more than 10 lines), but can relax this requirement for tasks where additional detail and comprehensiveness is important for the user's understanding. + +### Final answer structure and style guidelines + +You are producing plain text that will later be styled by the CLI. Follow these rules exactly. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value. + +**Section Headers** + +- Use only when they improve clarity — they are not mandatory for every answer. +- Choose descriptive names that fit the content +- Keep headers short (1–3 words) and in `**Title Case**`. Always start headers with `**` and end with `**` +- Leave no blank line before the first bullet under a header. +- Section headers should only be used where they genuinely improve scanability; avoid fragmenting the answer. + +**Bullets** + +- Use `-` followed by a space for every bullet. +- Merge related points when possible; avoid a bullet for every trivial detail. +- Keep bullets to one line unless breaking for clarity is unavoidable. +- Group into short lists (4–6 bullets) ordered by importance. +- Use consistent keyword phrasing and formatting across sections. + +**Monospace** + +- Wrap all commands, file paths, env vars, and code identifiers in backticks (`` `...` ``). +- Apply to inline examples and to bullet keywords if the keyword itself is a literal file/command. +- Never mix monospace and bold markers; choose one based on whether it’s a keyword (`**`) or inline code/path (`` ` ``). + +**File References** +When referencing files in your response, make sure to include the relevant start line and always follow the below rules: + * Use inline code to make file paths clickable. + * Each reference should have a stand alone path. Even if it's the same file. + * Accepted: absolute, workspace‑relative, a/ or b/ diff prefixes, or bare filename/suffix. + * Line/column (1‑based, optional): :line[:column] or #Lline[Ccolumn] (column defaults to 1). + * Do not use URIs like file://, vscode://, or https://. + * Do not provide range of lines + * Examples: src/app.ts, src/app.ts:42, b/server/index.js#L10, C:\repo\project\main.rs:12:5 + +**Structure** + +- Place related bullets together; don’t mix unrelated concepts in the same section. +- Order sections from general → specific → supporting info. +- For subsections (e.g., “Binaries” under “Rust Workspace”), introduce with a bolded keyword bullet, then list items under it. +- Match structure to complexity: + - Multi-part or detailed results → use clear headers and grouped bullets. + - Simple results → minimal headers, possibly just a short list or paragraph. + +**Tone** + +- Keep the voice collaborative and natural, like a coding partner handing off work. +- Be concise and factual — no filler or conversational commentary and avoid unnecessary repetition +- Use present tense and active voice (e.g., “Runs tests” not “This will run tests”). +- Keep descriptions self-contained; don’t refer to “above” or “below”. +- Use parallel structure in lists for consistency. + +**Don’t** + +- Don’t use literal words “bold” or “monospace” in the content. +- Don’t nest bullets or create deep hierarchies. +- Don’t output ANSI escape codes directly — the CLI renderer applies them. +- Don’t cram unrelated keywords into a single bullet; split for clarity. +- Don’t let keyword lists run long — wrap or reformat for scanability. + +Generally, ensure your final answers adapt their shape and depth to the request. For example, answers to code explanations should have a precise, structured explanation with code references that answer the question directly. For tasks with a simple implementation, lead with the outcome and supplement only with what’s needed for clarity. Larger changes can be presented as a logical walkthrough of your approach, grouping related steps, explaining rationale where it adds value, and highlighting next actions to accelerate the user. Your answers should provide the right level of detail while being easily scannable. + +For casual greetings, acknowledgements, or other one-off conversational messages that are not delivering substantive information or structured results, respond naturally without section headers or bullet formatting. + +# Tool Guidelines + +## Shell commands + +When using the shell, you must adhere to the following guidelines: + +- When searching for text or files, prefer using `rg` or `rg --files` respectively because `rg` is much faster than alternatives like `grep`. (If the `rg` command is not found, then use alternatives.) +- Do not use python scripts to attempt to output larger chunks of a file. + +## `update_plan` + +A tool named `update_plan` is available to you. You can use it to keep an up‑to‑date, step‑by‑step plan for the task. + +To create a new plan, call `update_plan` with a short list of 1‑sentence steps (no more than 5-7 words each) with a `status` for each step (`pending`, `in_progress`, or `completed`). + +When steps have been completed, use `update_plan` to mark each finished step as `completed` and the next step you are working on as `in_progress`. There should always be exactly one `in_progress` step until everything is done. You can mark multiple items as complete in a single `update_plan` call. + +If all steps are complete, ensure you call `update_plan` to mark all steps as `completed`. diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index df1b523cbc..533fe04d88 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -13,16 +13,19 @@ use std::time::Duration; use crate::ThreadId; use crate::approvals::ElicitationRequestEvent; +use crate::config_types::CollaborationMode; use crate::config_types::ReasoningSummary as ReasoningSummaryConfig; use crate::custom_prompts::CustomPrompt; use crate::items::TurnItem; use crate::message_history::HistoryEntry; +use crate::models::BaseInstructions; use crate::models::ContentItem; use crate::models::ResponseItem; use crate::num_format::format_with_separators; use crate::openai_models::ReasoningEffort as ReasoningEffortConfig; use crate::parse_command::ParsedCommand; use crate::plan_tool::UpdatePlanArgs; +use crate::request_user_input::RequestUserInputResponse; use crate::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; use mcp_types::CallToolResult; @@ -43,6 +46,7 @@ pub use crate::approvals::ApplyPatchApprovalRequestEvent; pub use crate::approvals::ElicitationAction; pub use crate::approvals::ExecApprovalRequestEvent; pub use crate::approvals::ExecPolicyAmendment; +pub use crate::request_user_input::RequestUserInputEvent; /// Open/close tags for special user-input blocks. Used across crates to avoid /// duplicated hardcoded strings. @@ -50,6 +54,8 @@ pub const USER_INSTRUCTIONS_OPEN_TAG: &str = ""; pub const USER_INSTRUCTIONS_CLOSE_TAG: &str = ""; pub const ENVIRONMENT_CONTEXT_OPEN_TAG: &str = ""; pub const ENVIRONMENT_CONTEXT_CLOSE_TAG: &str = ""; +pub const COLLABORATION_MODE_OPEN_TAG: &str = ""; +pub const COLLABORATION_MODE_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; /// Submission Queue Entry - requests from user @@ -78,7 +84,10 @@ pub enum Op { /// This server sends [`EventMsg::TurnAborted`] in response. Interrupt, - /// Input from the user + /// Legacy user input. + /// + /// Prefer [`Op::UserTurn`] so the caller provides full turn context + /// (cwd/approval/sandbox/model/etc.) for each turn. UserInput { /// User input items, see `InputItem` items: Vec, @@ -115,13 +124,19 @@ pub enum Op { summary: ReasoningSummaryConfig, // The JSON schema to use for the final assistant message final_output_json_schema: Option, + + /// EXPERIMENTAL - set a pre-set collaboration mode. + /// Takes precedence over model, effort, and developer instructions if set. + #[serde(skip_serializing_if = "Option::is_none")] + collaboration_mode: Option, }, /// Override parts of the persistent turn context for subsequent turns. /// /// All fields are optional; when omitted, the existing value is preserved. /// This does not enqueue any input – it only updates defaults used for - /// future `UserInput` turns. + /// turns that rely on persistent session-level context (for example, + /// [`Op::UserInput`]). OverrideTurnContext { /// Updated `cwd` for sandbox/tool calls. #[serde(skip_serializing_if = "Option::is_none")] @@ -150,6 +165,11 @@ pub enum Op { /// Updated reasoning summary preference (honored only for reasoning-capable models). #[serde(skip_serializing_if = "Option::is_none")] summary: Option, + + /// EXPERIMENTAL - set a pre-set collaboration mode. + /// Takes precedence over model, effort, and developer instructions if set. + #[serde(skip_serializing_if = "Option::is_none")] + collaboration_mode: Option, }, /// Approve a command execution @@ -178,6 +198,15 @@ pub enum Op { decision: ElicitationAction, }, + /// Resolve a request_user_input tool call. + #[serde(rename = "user_input_answer", alias = "request_user_input_response")] + UserInputAnswer { + /// Turn id for the in-flight request. + id: String, + /// User-provided answers. + response: RequestUserInputResponse, + }, + /// Append an entry to the persistent cross-session message history. /// /// Note the entry is not guaranteed to be logged if the user has @@ -710,6 +739,8 @@ pub enum EventMsg { ExecApprovalRequest(ExecApprovalRequestEvent), + RequestUserInput(RequestUserInputEvent), + ElicitationRequest(ElicitationRequestEvent), ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent), @@ -1376,6 +1407,22 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn forked_from_id(&self) -> Option { + match self { + InitialHistory::New => None, + InitialHistory::Resumed(resumed) => { + resumed.history.iter().find_map(|item| match item { + RolloutItem::SessionMeta(meta_line) => meta_line.meta.forked_from_id, + _ => None, + }) + } + InitialHistory::Forked(items) => items.iter().find_map(|item| match item { + RolloutItem::SessionMeta(meta_line) => Some(meta_line.meta.id), + _ => None, + }), + } + } + pub fn get_rollout_items(&self) -> Vec { match self { InitialHistory::New => Vec::new(), @@ -1408,6 +1455,23 @@ impl InitialHistory { ), } } + + pub fn get_base_instructions(&self) -> Option { + // TODO: SessionMeta should (in theory) always be first in the history, so we can probably only check the first item? + match self { + InitialHistory::New => None, + InitialHistory::Resumed(resumed) => { + resumed.history.iter().find_map(|item| match item { + RolloutItem::SessionMeta(meta_line) => meta_line.meta.base_instructions.clone(), + _ => None, + }) + } + InitialHistory::Forked(items) => items.iter().find_map(|item| match item { + RolloutItem::SessionMeta(meta_line) => meta_line.meta.base_instructions.clone(), + _ => None, + }), + } + } } #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema, TS, Default)] @@ -1456,30 +1520,41 @@ impl fmt::Display for SubAgentSource { } } +/// SessionMeta contains session-level data that doesn't correspond to a specific turn. +/// +/// NOTE: There used to be an `instructions` field here, which stored user_instructions, but we +/// now save that on TurnContext. base_instructions stores the base instructions for the session, +/// and should be used when there is no config override. #[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, TS)] pub struct SessionMeta { pub id: ThreadId, + #[serde(skip_serializing_if = "Option::is_none")] + pub forked_from_id: Option, pub timestamp: String, pub cwd: PathBuf, pub originator: String, pub cli_version: String, - pub instructions: Option, #[serde(default)] pub source: SessionSource, pub model_provider: Option, + /// base_instructions for the session. This *should* always be present when creating a new session, + /// but may be missing for older sessions. If not present, fall back to rendering the base_instructions + /// from ModelsManager. + pub base_instructions: Option, } impl Default for SessionMeta { fn default() -> Self { SessionMeta { id: ThreadId::default(), + forked_from_id: None, timestamp: String::new(), cwd: PathBuf::new(), originator: String::new(), cli_version: String::new(), - instructions: None, source: SessionSource::default(), model_provider: None, + base_instructions: None, } } } @@ -1531,8 +1606,6 @@ pub struct TurnContextItem { pub effort: Option, pub summary: ReasoningSummaryConfig, #[serde(skip_serializing_if = "Option::is_none")] - pub base_instructions: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub user_instructions: Option, #[serde(skip_serializing_if = "Option::is_none")] pub developer_instructions: Option, @@ -1656,21 +1729,18 @@ pub struct ReviewLineRange { pub end: u32, } -#[derive(Debug, Clone, Copy, Display, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[derive( + Debug, Clone, Copy, Display, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS, Default, +)] #[serde(rename_all = "snake_case")] pub enum ExecCommandSource { + #[default] Agent, UserShell, UnifiedExecStartup, UnifiedExecInteraction, } -impl Default for ExecCommandSource { - fn default() -> Self { - Self::Agent - } -} - #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ExecCommandBeginEvent { /// Identifier so this can be paired with the ExecCommandEnd event. @@ -2006,6 +2076,8 @@ pub struct SkillsListEntry { pub struct SessionConfiguredEvent { /// Name left as session_id instead of thread_id for backwards compatibility. pub session_id: ThreadId, + #[serde(skip_serializing_if = "Option::is_none")] + pub forked_from_id: Option, /// Tell the client what model is being queried. pub model: String, @@ -2177,8 +2249,8 @@ pub struct CollabAgentInteractionEndEvent { pub struct CollabWaitingBeginEvent { /// Thread ID of the sender. pub sender_thread_id: ThreadId, - /// Thread ID of the receiver. - pub receiver_thread_id: ThreadId, + /// Thread ID of the receivers. + pub receiver_thread_ids: Vec, /// ID of the waiting call. pub call_id: String, } @@ -2187,12 +2259,10 @@ pub struct CollabWaitingBeginEvent { pub struct CollabWaitingEndEvent { /// Thread ID of the sender. pub sender_thread_id: ThreadId, - /// Thread ID of the receiver. - pub receiver_thread_id: ThreadId, /// ID of the waiting call. pub call_id: String, - /// Last known status of the receiver agent reported to the sender agent. - pub status: AgentStatus, + /// Last known status of the receiver agents reported to the sender agent. + pub statuses: HashMap, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] @@ -2381,6 +2451,7 @@ mod tests { id: "1234".to_string(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: conversation_id, + forked_from_id: None, model: "codex-mini-latest".to_string(), model_provider_id: "openai".to_string(), approval_policy: AskForApproval::Never, diff --git a/codex-rs/protocol/src/request_user_input.rs b/codex-rs/protocol/src/request_user_input.rs new file mode 100644 index 0000000000..44ad3b6302 --- /dev/null +++ b/codex-rs/protocol/src/request_user_input.rs @@ -0,0 +1,48 @@ +use std::collections::HashMap; + +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct RequestUserInputQuestionOption { + pub label: String, + pub description: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct RequestUserInputQuestion { + pub id: String, + pub header: String, + pub question: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub options: Option>, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct RequestUserInputArgs { + pub questions: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct RequestUserInputAnswer { + pub selected: Vec, + pub other: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct RequestUserInputResponse { + pub answers: HashMap, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct RequestUserInputEvent { + /// Responses API call id for the associated tool call, if available. + pub call_id: String, + /// Turn ID that this request belongs to. + /// Uses `#[serde(default)]` for backwards compatibility. + #[serde(default)] + pub turn_id: String, + pub questions: Vec, +} diff --git a/codex-rs/protocol/src/thread_id.rs b/codex-rs/protocol/src/thread_id.rs index 8589566a25..7b27db8364 100644 --- a/codex-rs/protocol/src/thread_id.rs +++ b/codex-rs/protocol/src/thread_id.rs @@ -70,10 +70,6 @@ impl JsonSchema for ThreadId { } } -/// Backward-compatible alias for the previous name. -#[deprecated(note = "use ThreadId instead")] -pub type ConversationId = ThreadId; - #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/protocol/src/user_input.rs b/codex-rs/protocol/src/user_input.rs index b9ee693d85..0f32f5a33d 100644 --- a/codex-rs/protocol/src/user_input.rs +++ b/codex-rs/protocol/src/user_input.rs @@ -36,7 +36,35 @@ pub struct TextElement { /// Byte range in the parent `text` buffer that this element occupies. pub byte_range: ByteRange, /// Optional human-readable placeholder for the element, displayed in the UI. - pub placeholder: Option, + placeholder: Option, +} + +impl TextElement { + pub fn new(byte_range: ByteRange, placeholder: Option) -> Self { + Self { + byte_range, + placeholder, + } + } + + pub fn set_placeholder(&mut self, placeholder: Option) { + self.placeholder = placeholder; + } + + /// Returns the stored placeholder without falling back to the text buffer. + /// + /// This is intended only for protocol conversions where the full text is not + /// available; prefer `placeholder(text)` for UI logic. + #[doc(hidden)] + pub fn _placeholder_for_conversion_only(&self) -> Option<&str> { + self.placeholder.as_deref() + } + + pub fn placeholder<'a>(&'a self, text: &'a str) -> Option<&'a str> { + self.placeholder + .as_deref() + .or_else(|| text.get(self.byte_range.start..self.byte_range.end)) + } } #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, TS, JsonSchema)] diff --git a/codex-rs/responses-api-proxy/Cargo.toml b/codex-rs/responses-api-proxy/Cargo.toml index e0ea600033..a2226dba6a 100644 --- a/codex-rs/responses-api-proxy/Cargo.toml +++ b/codex-rs/responses-api-proxy/Cargo.toml @@ -21,7 +21,7 @@ clap = { workspace = true, features = ["derive"] } codex-process-hardening = { workspace = true } ctor = { workspace = true } libc = { workspace = true } -reqwest = { workspace = true, features = ["blocking", "json", "rustls-tls"] } +reqwest = { workspace = true, features = ["blocking", "json", "rustls-tls-webpki-roots", "rustls-tls-native-roots"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tiny_http = { workspace = true } diff --git a/codex-rs/rmcp-client/Cargo.toml b/codex-rs/rmcp-client/Cargo.toml index 8aa7512faf..2e874c6089 100644 --- a/codex-rs/rmcp-client/Cargo.toml +++ b/codex-rs/rmcp-client/Cargo.toml @@ -23,7 +23,8 @@ oauth2 = "5" reqwest = { version = "0.12", default-features = false, features = [ "json", "stream", - "rustls-tls", + "rustls-tls-webpki-roots", + "rustls-tls-native-roots", ] } rmcp = { workspace = true, default-features = false, features = [ "auth", diff --git a/codex-rs/rust-toolchain.toml b/codex-rs/rust-toolchain.toml index 7187a33189..05eeaac960 100644 --- a/codex-rs/rust-toolchain.toml +++ b/codex-rs/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "1.90.0" +channel = "1.92.0" components = ["clippy", "rustfmt", "rust-src"] diff --git a/codex-rs/scripts/setup-windows.ps1 b/codex-rs/scripts/setup-windows.ps1 index 554dc63352..33b6b0c352 100644 --- a/codex-rs/scripts/setup-windows.ps1 +++ b/codex-rs/scripts/setup-windows.ps1 @@ -179,7 +179,7 @@ if (-not (Ensure-Command 'cargo')) { Write-Host "==> Configuring Rust toolchain per rust-toolchain.toml" -ForegroundColor Cyan # Pin to the workspace toolchain and install components -$toolchain = '1.90.0' +$toolchain = '1.92.0' & rustup toolchain install $toolchain --profile minimal | Out-Host & rustup default $toolchain | Out-Host & rustup component add clippy rustfmt rust-src --toolchain $toolchain | Out-Host diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index f1d6ca5da4..b361425aeb 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -15,6 +15,8 @@ use crate::external_editor; use crate::file_search::FileSearchManager; use crate::history_cell; use crate::history_cell::HistoryCell; +#[cfg(not(debug_assertions))] +use crate::history_cell::UpdateAvailableHistoryCell; use crate::model_migration::ModelMigrationOutcome; use crate::model_migration::migration_copy_for_models; use crate::model_migration::run_model_migration_prompt; @@ -37,6 +39,7 @@ use codex_core::models_manager::manager::RefreshStrategy; 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::Event; use codex_core::protocol::EventMsg; use codex_core::protocol::FinalOutput; use codex_core::protocol::ListSkillsResponseEvent; @@ -58,6 +61,8 @@ use ratatui::text::Line; use ratatui::widgets::Paragraph; use ratatui::widgets::Wrap; use std::collections::BTreeMap; +use std::collections::HashMap; +use std::collections::VecDeque; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; @@ -66,11 +71,9 @@ use std::sync::atomic::Ordering; use std::thread; use std::time::Duration; use tokio::select; +use tokio::sync::broadcast; use tokio::sync::mpsc::unbounded_channel; -#[cfg(not(debug_assertions))] -use crate::history_cell::UpdateAvailableHistoryCell; - const EXTERNAL_EDITOR_HINT: &str = "Save and close external editor to continue."; #[derive(Debug, Clone)] @@ -269,7 +272,6 @@ async fn handle_model_migration_prompt_if_needed( from_model: model.to_string(), to_model: target_model.clone(), }); - config.model = Some(target_model.clone()); let mapped_effort = if let Some(reasoning_effort_mapping) = reasoning_effort_mapping && let Some(reasoning_effort) = config.model_reasoning_effort @@ -282,8 +284,8 @@ async fn handle_model_migration_prompt_if_needed( config.model_reasoning_effort }; + config.model = Some(target_model.clone()); config.model_reasoning_effort = mapped_effort; - app_event_tx.send(AppEvent::UpdateModel(target_model.clone())); app_event_tx.send(AppEvent::UpdateReasoningEffort(mapped_effort)); app_event_tx.send(AppEvent::PersistModelSelection { @@ -318,7 +320,6 @@ pub(crate) struct App { pub(crate) auth_manager: Arc, /// Config is stored here so we can recreate ChatWidgets as needed. pub(crate) config: Config, - pub(crate) current_model: String, pub(crate) active_profile: Option, pub(crate) file_search: FileSearchManager, @@ -337,6 +338,11 @@ pub(crate) struct App { // Esc-backtracking state grouped pub(crate) backtrack: crate::app_backtrack::BacktrackState, + /// When set, the next draw re-renders the transcript into terminal scrollback once. + /// + /// This is used after a confirmed thread rollback to ensure scrollback reflects the trimmed + /// transcript cells. + pub(crate) backtrack_render_pending: bool, pub(crate) feedback: codex_feedback::CodexFeedback, /// Set when the user confirms an update; propagated on exit. pub(crate) pending_update_action: Option, @@ -347,11 +353,40 @@ pub(crate) struct App { // One-shot suppression of the next world-writable scan after user confirmation. skip_world_writable_scan_once: bool, + + // TODO(jif) drop once new UX is here. + // Track external agent approvals spawned via AgentControl. + /// Map routed approval IDs to their originating external threads and original IDs. + external_approval_routes: HashMap, + /// Buffered Codex events while external approvals are pending. + paused_codex_events: VecDeque, } impl App { + pub fn chatwidget_init_for_forked_or_resumed_thread( + &self, + tui: &mut tui::Tui, + cfg: codex_core::config::Config, + ) -> crate::chatwidget::ChatWidgetInit { + crate::chatwidget::ChatWidgetInit { + config: cfg, + frame_requester: tui.frame_requester(), + app_event_tx: self.app_event_tx.clone(), + // Fork/resume bootstraps here don't carry any prefilled message content. + initial_user_message: None, + 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, + model: Some(self.chat_widget.current_model().to_string()), + } + } + async fn shutdown_current_thread(&mut self) { if let Some(thread_id) = self.chat_widget.thread_id() { + // Clear any in-flight rollback guard when switching threads. + self.backtrack.pending_rollback = None; self.suppress_shutdown_complete = true; self.chat_widget.submit_op(Op::Shutdown); self.server.remove_thread(&thread_id).await; @@ -411,14 +446,18 @@ impl App { config: config.clone(), frame_requester: tui.frame_requester(), app_event_tx: app_event_tx.clone(), - initial_prompt: initial_prompt.clone(), - initial_images: initial_images.clone(), + initial_user_message: crate::chatwidget::create_initial_user_message( + initial_prompt.clone(), + initial_images.clone(), + // CLI prompt args are plain strings, so they don't provide element ranges. + Vec::new(), + ), enhanced_keys_supported, auth_manager: auth_manager.clone(), models_manager: thread_manager.get_models_manager(), feedback: feedback.clone(), is_first_run, - model: config.model.clone(), + model: Some(model.clone()), }; ChatWidget::new(init, thread_manager.clone()) } @@ -434,8 +473,12 @@ impl App { config: config.clone(), frame_requester: tui.frame_requester(), app_event_tx: app_event_tx.clone(), - initial_prompt: initial_prompt.clone(), - initial_images: initial_images.clone(), + initial_user_message: crate::chatwidget::create_initial_user_message( + initial_prompt.clone(), + initial_images.clone(), + // CLI prompt args are plain strings, so they don't provide element ranges. + Vec::new(), + ), enhanced_keys_supported, auth_manager: auth_manager.clone(), models_manager: thread_manager.get_models_manager(), @@ -457,8 +500,12 @@ impl App { config: config.clone(), frame_requester: tui.frame_requester(), app_event_tx: app_event_tx.clone(), - initial_prompt: initial_prompt.clone(), - initial_images: initial_images.clone(), + initial_user_message: crate::chatwidget::create_initial_user_message( + initial_prompt.clone(), + initial_images.clone(), + // CLI prompt args are plain strings, so they don't provide element ranges. + Vec::new(), + ), enhanced_keys_supported, auth_manager: auth_manager.clone(), models_manager: thread_manager.get_models_manager(), @@ -482,7 +529,6 @@ impl App { chat_widget, auth_manager: auth_manager.clone(), config, - current_model: model.clone(), active_profile, file_search, enhanced_keys_supported, @@ -492,10 +538,13 @@ impl App { has_emitted_history_lines: false, commit_anim_running: Arc::new(AtomicBool::new(false)), backtrack: BacktrackState::default(), + backtrack_render_pending: false, feedback: feedback.clone(), pending_update_action: None, suppress_shutdown_complete: false, skip_world_writable_scan_once: false, + external_approval_routes: HashMap::new(), + paused_codex_events: VecDeque::new(), }; // On startup, if Agent mode (workspace-write) or ReadOnly is active, warn about world-writable dirs on Windows. @@ -548,6 +597,9 @@ impl App { tui.frame_requester().schedule_frame(); + let mut thread_created_rx = thread_manager.subscribe_thread_created(); + let mut listen_for_threads = true; + let exit_reason = loop { let control = select! { Some(event) = app_event_rx.recv() => { @@ -556,6 +608,21 @@ impl App { Some(event) = tui_events.next() => { app.handle_tui_event(tui, event).await? } + // Listen on new thread creation due to collab tools. + created = thread_created_rx.recv(), if listen_for_threads => { + match created { + Ok(thread_id) => { + app.handle_thread_created(thread_id).await?; + } + Err(broadcast::error::RecvError::Lagged(_)) => { + tracing::warn!("thread_created receiver lagged; skipping resync"); + } + Err(broadcast::error::RecvError::Closed) => { + listen_for_threads = false; + } + } + AppRunControl::Continue + } }; match control { AppRunControl::Continue => {} @@ -592,6 +659,10 @@ impl App { self.chat_widget.handle_paste(pasted); } TuiEvent::Draw => { + if self.backtrack_render_pending { + self.backtrack_render_pending = false; + self.render_transcript_once(tui); + } self.chat_widget.maybe_post_pending_notification(tui); if self .chat_widget @@ -620,31 +691,29 @@ impl App { } async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result { - let model_info = self - .server - .get_models_manager() - .get_model_info(self.current_model.as_str(), &self.config) - .await; match event { AppEvent::NewSession => { + let model = self.chat_widget.current_model().to_string(); let summary = session_summary(self.chat_widget.token_usage(), self.chat_widget.thread_id()); self.shutdown_current_thread().await; + if let Err(err) = self.server.remove_and_close_all_threads().await { + tracing::warn!(error = %err, "failed to close all threads"); + } let init = crate::chatwidget::ChatWidgetInit { config: self.config.clone(), frame_requester: tui.frame_requester(), app_event_tx: self.app_event_tx.clone(), - initial_prompt: None, - initial_images: Vec::new(), + // New sessions start without prefilled message content. + initial_user_message: None, 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, - model: Some(self.current_model.clone()), + model: Some(model), }; self.chat_widget = ChatWidget::new(init, self.server.clone()); - self.current_model = model_info.slug.clone(); if let Some(summary) = summary { let mut lines: Vec> = vec![summary.usage_line.clone().into()]; if let Some(command) = summary.resume_command { @@ -680,25 +749,15 @@ impl App { { Ok(resumed) => { self.shutdown_current_thread().await; - let init = crate::chatwidget::ChatWidgetInit { - config: self.config.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, - model: Some(self.current_model.clone()), - }; + let init = self.chatwidget_init_for_forked_or_resumed_thread( + tui, + self.config.clone(), + ); self.chat_widget = ChatWidget::new_from_existing( init, resumed.thread, resumed.session_configured, ); - self.current_model = model_info.slug.clone(); if let Some(summary) = summary { let mut lines: Vec> = vec![summary.usage_line.clone().into()]; @@ -728,73 +787,51 @@ impl App { // Leaving alt-screen may blank the inline viewport; force a redraw either way. tui.frame_requester().schedule_frame(); } - AppEvent::OpenForkPicker => { - match crate::resume_picker::run_fork_picker( - tui, - &self.config.codex_home, - &self.config.model_provider_id, - false, - ) - .await? - { - SessionSelection::Fork(path) => { - let summary = session_summary( - self.chat_widget.token_usage(), - self.chat_widget.thread_id(), - ); - match self - .server - .fork_thread(usize::MAX, self.config.clone(), path.clone()) - .await - { - Ok(forked) => { - self.shutdown_current_thread().await; - let init = crate::chatwidget::ChatWidgetInit { - config: self.config.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, - model: Some(self.current_model.clone()), - }; - self.chat_widget = ChatWidget::new_from_existing( - init, - forked.thread, - forked.session_configured, - ); - self.current_model = model_info.slug.clone(); - if let Some(summary) = summary { - let mut lines: Vec> = - vec![summary.usage_line.clone().into()]; - if let Some(command) = summary.resume_command { - let spans = vec![ - "To continue this session, run ".into(), - command.cyan(), - ]; - lines.push(spans.into()); - } - self.chat_widget.add_plain_history_lines(lines); + AppEvent::ForkCurrentSession => { + let summary = + session_summary(self.chat_widget.token_usage(), self.chat_widget.thread_id()); + if let Some(path) = self.chat_widget.rollout_path() { + match self + .server + .fork_thread(usize::MAX, self.config.clone(), path.clone()) + .await + { + Ok(forked) => { + self.shutdown_current_thread().await; + let init = self.chatwidget_init_for_forked_or_resumed_thread( + tui, + self.config.clone(), + ); + self.chat_widget = ChatWidget::new_from_existing( + init, + forked.thread, + forked.session_configured, + ); + if let Some(summary) = summary { + let mut lines: Vec> = + vec![summary.usage_line.clone().into()]; + if let Some(command) = summary.resume_command { + let spans = vec![ + "To continue this session, run ".into(), + command.cyan(), + ]; + lines.push(spans.into()); } - } - Err(err) => { - let path_display = path.display(); - self.chat_widget.add_error_message(format!( - "Failed to fork session from {path_display}: {err}" - )); + self.chat_widget.add_plain_history_lines(lines); } } + Err(err) => { + let path_display = path.display(); + self.chat_widget.add_error_message(format!( + "Failed to fork current session from {path_display}: {err}" + )); + } } - SessionSelection::Exit - | SessionSelection::StartFresh - | SessionSelection::Resume(_) => {} + } else { + self.chat_widget + .add_error_message("Current session is not ready to fork yet.".to_string()); } - // Leaving alt-screen may blank the inline viewport; force a redraw either way. tui.frame_requester().schedule_frame(); } AppEvent::InsertHistoryCell(cell) => { @@ -846,18 +883,18 @@ impl App { self.chat_widget.on_commit_tick(); } AppEvent::CodexEvent(event) => { - if self.suppress_shutdown_complete - && matches!(event.msg, EventMsg::ShutdownComplete) - { - self.suppress_shutdown_complete = false; + if !self.external_approval_routes.is_empty() { + // Store the events while the approval is pending. + self.paused_codex_events.push_back(event); return Ok(AppRunControl::Continue); } - if let EventMsg::ListSkillsResponse(response) = &event.msg { - let cwd = self.chat_widget.config_ref().cwd.clone(); - let errors = errors_for_cwd(&cwd, response); - emit_skill_load_warnings(&self.app_event_tx, &errors); + self.handle_codex_event_now(event); + if self.backtrack_render_pending { + tui.frame_requester().schedule_frame(); } - self.chat_widget.handle_codex_event(event); + } + AppEvent::ExternalApprovalRequest { thread_id, event } => { + self.handle_external_approval_request(thread_id, event); } AppEvent::Exit(mode) => match mode { ExitMode::ShutdownFirst => self.chat_widget.submit_op(Op::Shutdown), @@ -868,7 +905,54 @@ impl App { AppEvent::FatalExitRequest(message) => { return Ok(AppRunControl::Exit(ExitReason::Fatal(message))); } - AppEvent::CodexOp(op) => self.chat_widget.submit_op(op), + AppEvent::CodexOp(op) => match op { + // Catch potential approvals coming from an external thread and treat them + // directly. This support both command and patch approval. In such case + // the approval get transferred to the corresponding thread and the external + // approval map (`external_approval_routes`) is updated. + Op::ExecApproval { id, decision } => { + if let Some((thread_id, original_id)) = + self.external_approval_routes.remove(&id) + { + // Approval of a sub-agent. + self.forward_external_op( + thread_id, + Op::ExecApproval { + id: original_id, + decision, + }, + ) + .await; + self.finish_external_approval(); + } else { + // This is an approval but not external. + self.chat_widget + .submit_op(Op::ExecApproval { id, decision }); + } + } + Op::PatchApproval { id, decision } => { + if let Some((thread_id, original_id)) = + self.external_approval_routes.remove(&id) + { + // Approval of a sub-agent. + self.forward_external_op( + thread_id, + Op::PatchApproval { + id: original_id, + decision, + }, + ) + .await; + self.finish_external_approval(); + } else { + // This is an approval but not external. + self.chat_widget + .submit_op(Op::PatchApproval { id, decision }); + } + } + // Standard path where this is not an external approval response. + _ => self.chat_widget.submit_op(op), + }, AppEvent::DiffResult(text) => { // Clear the in-progress state in the bottom pane self.chat_widget.on_diff_complete(); @@ -901,7 +985,11 @@ impl App { } AppEvent::UpdateModel(model) => { self.chat_widget.set_model(&model); - self.current_model = model; + } + AppEvent::UpdateCollaborationMode(mode) => { + let model = mode.model().to_string(); + self.chat_widget.set_collaboration_mode(mode); + self.chat_widget.set_model(&model); } AppEvent::OpenReasoningPopup { model } => { self.chat_widget.open_reasoning_popup(model); @@ -1048,6 +1136,7 @@ impl App { model: None, effort: None, summary: None, + collaboration_mode: None, }, )); self.app_event_tx @@ -1343,6 +1432,90 @@ impl App { Ok(AppRunControl::Continue) } + fn handle_codex_event_now(&mut self, event: Event) { + if self.suppress_shutdown_complete && matches!(event.msg, EventMsg::ShutdownComplete) { + self.suppress_shutdown_complete = false; + return; + } + if let EventMsg::ListSkillsResponse(response) = &event.msg { + let cwd = self.chat_widget.config_ref().cwd.clone(); + let errors = errors_for_cwd(&cwd, response); + emit_skill_load_warnings(&self.app_event_tx, &errors); + } + self.handle_backtrack_event(&event.msg); + self.chat_widget.handle_codex_event(event); + } + + /// Routes external approval request events through the chat widget by + /// rewriting the event id to include the originating thread. + /// + /// `thread_id` is the external thread that issued the approval request. + /// `event` is the approval request event whose id is rewritten so replies + /// can be routed back to the correct thread. + fn handle_external_approval_request(&mut self, thread_id: ThreadId, mut event: Event) { + match &mut event.msg { + EventMsg::ExecApprovalRequest(_) | EventMsg::ApplyPatchApprovalRequest(_) => { + let original_id = event.id.clone(); + let routing_id = format!("{thread_id}:{original_id}"); + self.external_approval_routes + .insert(routing_id.clone(), (thread_id, original_id)); + event.id = routing_id; + } + _ => return, + } + self.chat_widget.handle_codex_event(event); + } + + async fn forward_external_op(&self, thread_id: ThreadId, op: Op) { + let thread = match self.server.get_thread(thread_id).await { + Ok(thread) => thread, + Err(err) => { + tracing::warn!("failed to find thread {thread_id} for approval response: {err}"); + return; + } + }; + if let Err(err) = thread.submit(op).await { + tracing::warn!("failed to submit approval response to thread {thread_id}: {err}"); + } + } + + fn finish_external_approval(&mut self) { + if self.external_approval_routes.is_empty() { + while let Some(event) = self.paused_codex_events.pop_front() { + self.handle_codex_event_now(event); + } + } + } + + async fn handle_thread_created(&mut self, thread_id: ThreadId) -> Result<()> { + let thread = match self.server.get_thread(thread_id).await { + Ok(thread) => thread, + Err(err) => { + tracing::warn!("failed to attach listener for thread {thread_id}: {err}"); + return Ok(()); + } + }; + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + loop { + let event = match thread.next_event().await { + Ok(event) => event, + Err(err) => { + tracing::debug!("external thread {thread_id} listener stopped: {err}"); + break; + } + }; + match event.msg { + EventMsg::ExecApprovalRequest(_) | EventMsg::ApplyPatchApprovalRequest(_) => { + app_event_tx.send(AppEvent::ExternalApprovalRequest { thread_id, event }); + } + _ => {} + } + } + }); + Ok(()) + } + fn reasoning_label(reasoning_effort: Option) -> &'static str { match reasoning_effort { Some(ReasoningEffortConfig::Minimal) => "minimal", @@ -1366,8 +1539,10 @@ impl App { } fn on_update_reasoning_effort(&mut self, effort: Option) { - self.chat_widget.set_reasoning_effort(effort); + // TODO(aibrahim): Remove this and don't use config as a state object. + // Instead, explicitly pass the stored collaboration mode's effort into new sessions. self.config.model_reasoning_effort = effort; + self.chat_widget.set_reasoning_effort(effort); } async fn launch_external_editor(&mut self, tui: &mut tui::Tui) { @@ -1569,7 +1744,6 @@ mod tests { async fn make_test_app() -> App { let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await; let config = chat_widget.config_ref().clone(); - let current_model = "gpt-5.2-codex".to_string(); let server = Arc::new(ThreadManager::with_models_provider( CodexAuth::from_api_key("Test API Key"), config.model_provider.clone(), @@ -1584,7 +1758,6 @@ mod tests { chat_widget, auth_manager, config, - current_model, active_profile: None, file_search, transcript_cells: Vec::new(), @@ -1594,10 +1767,13 @@ mod tests { enhanced_keys_supported: false, commit_anim_running: Arc::new(AtomicBool::new(false)), backtrack: BacktrackState::default(), + backtrack_render_pending: false, feedback: codex_feedback::CodexFeedback::new(), pending_update_action: None, suppress_shutdown_complete: false, skip_world_writable_scan_once: false, + external_approval_routes: HashMap::new(), + paused_codex_events: VecDeque::new(), } } @@ -1608,7 +1784,6 @@ mod tests { ) { let (chat_widget, app_event_tx, rx, op_rx) = make_chatwidget_manual_with_sender().await; let config = chat_widget.config_ref().clone(); - let current_model = "gpt-5.2-codex".to_string(); let server = Arc::new(ThreadManager::with_models_provider( CodexAuth::from_api_key("Test API Key"), config.model_provider.clone(), @@ -1624,7 +1799,6 @@ mod tests { chat_widget, auth_manager, config, - current_model, active_profile: None, file_search, transcript_cells: Vec::new(), @@ -1634,10 +1808,13 @@ mod tests { enhanced_keys_supported: false, commit_anim_running: Arc::new(AtomicBool::new(false)), backtrack: BacktrackState::default(), + backtrack_render_pending: false, feedback: codex_feedback::CodexFeedback::new(), pending_update_action: None, suppress_shutdown_complete: false, skip_world_writable_scan_once: false, + external_approval_routes: HashMap::new(), + paused_codex_events: VecDeque::new(), }, rx, op_rx, @@ -1806,20 +1983,19 @@ mod tests { } #[tokio::test] - async fn update_reasoning_effort_updates_config() { + async fn update_reasoning_effort_updates_collaboration_mode() { let mut app = make_test_app().await; - app.config.model_reasoning_effort = Some(ReasoningEffortConfig::Medium); app.chat_widget .set_reasoning_effort(Some(ReasoningEffortConfig::Medium)); app.on_update_reasoning_effort(Some(ReasoningEffortConfig::High)); assert_eq!( - app.config.model_reasoning_effort, + app.chat_widget.current_reasoning_effort(), Some(ReasoningEffortConfig::High) ); assert_eq!( - app.chat_widget.config_ref().model_reasoning_effort, + app.config.model_reasoning_effort, Some(ReasoningEffortConfig::High) ); } @@ -1831,6 +2007,8 @@ mod tests { let user_cell = |text: &str| -> Arc { Arc::new(UserHistoryCell { message: text.to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), }) as Arc }; let agent_cell = |text: &str| -> Arc { @@ -1843,6 +2021,7 @@ mod tests { let make_header = |is_first| { let event = SessionConfiguredEvent { session_id: ThreadId::new(), + forked_from_id: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -1856,9 +2035,14 @@ mod tests { }; Arc::new(new_session_info( app.chat_widget.config_ref(), - app.current_model.as_str(), + app.chat_widget.current_model(), event, is_first, + app.chat_widget + .config_ref() + .features + .enabled(codex_core::features::Feature::CollaborationModes), + app.chat_widget.stored_collaboration_mode().clone(), )) as Arc }; @@ -1884,6 +2068,7 @@ mod tests { id: String::new(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: base_id, + forked_from_id: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -1926,6 +2111,7 @@ mod tests { let thread_id = ThreadId::new(); let event = SessionConfiguredEvent { session_id: thread_id, + forked_from_id: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 43dacb573a..9a77b6bed7 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -3,6 +3,18 @@ //! This file owns backtrack mode (Esc/Enter navigation in the transcript overlay) and also //! mediates a key rendering boundary for the transcript overlay. //! +//! Overall goal: keep the main chat view and the transcript overlay in sync while allowing +//! users to "rewind" to an earlier user message. We stage a rollback request, wait for core to +//! confirm it, then trim the local transcript to the matching history boundary. This avoids UI +//! state diverging from the agent if a rollback fails or targets a different thread. +//! +//! Backtrack operates as a small state machine: +//! - The first `Esc` in the main view "primes" the feature and captures a base thread id. +//! - A subsequent `Esc` opens the transcript overlay (`Ctrl+T`) and highlights a user message. +//! - `Enter` requests a rollback from core and records a `pending_rollback` guard. +//! - Only after receiving `EventMsg::ThreadRolledBack` do we trim local transcript state and +//! schedule a one-time scrollback refresh. +//! //! The transcript overlay (`Ctrl+T`) renders committed transcript cells plus a render-only live //! tail derived from the current in-flight `ChatWidget.active_cell`. //! @@ -20,6 +32,9 @@ use crate::history_cell::UserHistoryCell; use crate::pager_overlay::Overlay; use crate::tui; use crate::tui::TuiEvent; +use codex_core::protocol::CodexErrorInfo; +use codex_core::protocol::ErrorEvent; +use codex_core::protocol::EventMsg; use codex_core::protocol::Op; use codex_protocol::ThreadId; use color_eyre::eyre::Result; @@ -33,24 +48,54 @@ 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 rollback. + /// + /// If the current thread changes, backtrack selections become invalid and must be ignored. pub(crate) base_id: Option, - /// Index in the transcript of the last user message. + /// Index of the currently highlighted user message. + /// + /// This is an index into the filtered "user messages since the last session start" view, + /// not an index into `transcript_cells`. `usize::MAX` indicates "no selection". pub(crate) nth_user_message: usize, /// True when the transcript overlay is showing a backtrack preview. pub(crate) overlay_preview_active: bool, + /// Pending rollback request awaiting confirmation from core. + /// + /// This acts as a guardrail: once we request a rollback, we block additional backtrack + /// submissions until core responds with either a success or failure event. + pub(crate) pending_rollback: Option, } +/// A user-visible backtrack choice that can be confirmed into a rollback request. #[derive(Debug, Clone)] pub(crate) struct BacktrackSelection { + /// The selected user message, counted from the most recent session start. + /// + /// This value is used both to compute the rollback depth and to trim the local transcript + /// after core confirms the rollback. pub(crate) nth_user_message: usize, + /// Composer prefill derived from the selected user message. + /// + /// This is applied immediately on selection confirmation; if the rollback fails, the prefill + /// remains as a convenience so the user can retry or edit. pub(crate) prefill: String, } +/// An in-flight rollback requested from core. +/// +/// We keep enough information to apply the corresponding local trim only if the response targets +/// the same active thread we issued the request for. +#[derive(Debug, Clone)] +pub(crate) struct PendingBacktrackRollback { + pub(crate) selection: BacktrackSelection, + pub(crate) thread_id: Option, +} + impl App { - /// Route overlay events when transcript overlay is active. - /// - If backtrack preview is active: Esc steps selection; Enter confirms. - /// - Otherwise: Esc begins preview; all other events forward to overlay. - /// interactions (Esc to step target, Enter to confirm) and overlay lifecycle. + /// Route overlay events while the transcript overlay is active. + /// + /// If backtrack preview is active, Esc / Left steps selection, Right steps forward, Enter + /// confirms. Otherwise, Esc begins preview mode and all other events are forwarded to the + /// overlay. pub(crate) async fn handle_backtrack_overlay_event( &mut self, tui: &mut tui::Tui, @@ -66,6 +111,22 @@ impl App { self.overlay_step_backtrack(tui, event)?; Ok(true) } + TuiEvent::Key(KeyEvent { + code: KeyCode::Left, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + }) => { + self.overlay_step_backtrack(tui, event)?; + Ok(true) + } + TuiEvent::Key(KeyEvent { + code: KeyCode::Right, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + }) => { + self.overlay_step_backtrack_forward(tui, event)?; + Ok(true) + } TuiEvent::Key(KeyEvent { code: KeyCode::Enter, kind: KeyEventKind::Press, @@ -112,22 +173,41 @@ impl App { } /// Stage a backtrack and request thread history from the agent. + /// + /// We send the rollback request immediately, but we only mutate the transcript after core + /// confirms success so the UI cannot get ahead of the actual thread state. + /// + /// The composer prefill is applied immediately as a UX convenience; it does not imply that + /// core has accepted the rollback. pub(crate) fn apply_backtrack_rollback(&mut self, selection: BacktrackSelection) { let user_total = user_count(&self.transcript_cells); if user_total == 0 { return; } + if self.backtrack.pending_rollback.is_some() { + self.chat_widget + .add_error_message("Backtrack rollback already in progress.".to_string()); + 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; } + let prefill = selection.prefill.clone(); + self.backtrack.pending_rollback = Some(PendingBacktrackRollback { + selection, + thread_id: self.chat_widget.thread_id(), + }); 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); + if !prefill.is_empty() { + // TODO: Rehydrate text_elements/local_image_paths from the selected user cell so + // backtrack preserves image placeholders and attachments. + self.chat_widget + .set_composer_text(prefill, Vec::new(), Vec::new()); } } @@ -217,6 +297,27 @@ impl App { tui.frame_requester().schedule_frame(); } + /// Step selection to the next newer user message and update overlay. + fn step_forward_backtrack_and_highlight(&mut self, tui: &mut tui::Tui) { + let count = user_count(&self.transcript_cells); + if count == 0 { + return; + } + + let last_index = count.saturating_sub(1); + let next_selection = if self.backtrack.nth_user_message == usize::MAX { + last_index + } else { + self.backtrack + .nth_user_message + .saturating_add(1) + .min(last_index) + }; + + 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_internal(&mut self, nth_user_message: usize) { if let Some(cell_idx) = nth_user_position(&self.transcript_cells, nth_user_message) { @@ -290,7 +391,6 @@ impl App { 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(); } } @@ -305,6 +405,20 @@ impl App { Ok(()) } + /// Handle Right in overlay backtrack preview: step selection forward if armed, else forward. + fn overlay_step_backtrack_forward( + &mut self, + tui: &mut tui::Tui, + event: TuiEvent, + ) -> Result<()> { + if self.backtrack.base_id.is_some() { + self.step_forward_backtrack_and_highlight(tui); + } else { + self.overlay_forward_event(tui, event)?; + } + Ok(()) + } + /// Confirm a primed backtrack from the main view (no overlay visible). /// Computes the prefill from the selected user message for rollback. pub(crate) fn confirm_backtrack_from_main(&mut self) -> Option { @@ -328,10 +442,39 @@ impl App { selection: BacktrackSelection, ) { self.apply_backtrack_rollback(selection); - self.render_transcript_once(tui); tui.frame_requester().schedule_frame(); } + pub(crate) fn handle_backtrack_event(&mut self, event: &EventMsg) { + match event { + EventMsg::ThreadRolledBack(_) => self.finish_pending_backtrack(), + EventMsg::Error(ErrorEvent { + codex_error_info: Some(CodexErrorInfo::ThreadRollbackFailed), + .. + }) => { + // Core rejected the rollback; clear the guard so the user can retry. + self.backtrack.pending_rollback = None; + } + _ => {} + } + } + + /// Finish a pending rollback by applying the local trim and scheduling a scrollback refresh. + /// + /// We ignore events that do not correspond to the currently active thread to avoid applying + /// stale updates after a session switch. + fn finish_pending_backtrack(&mut self) { + let Some(pending) = self.backtrack.pending_rollback.take() else { + return; + }; + if pending.thread_id != self.chat_widget.thread_id() { + // Ignore rollbacks targeting a prior thread. + return; + } + self.trim_transcript_for_backtrack(pending.selection.nth_user_message); + self.backtrack_render_pending = true; + } + 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) { @@ -350,7 +493,7 @@ impl App { }) } - /// Trim transcript_cells to preserve only content up to the selected user message. + /// Trim `transcript_cells` to preserve only content before 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); } @@ -414,6 +557,8 @@ mod tests { let mut cells: Vec> = vec![ Arc::new(UserHistoryCell { message: "first user".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), }) as Arc, Arc::new(AgentMessageCell::new(vec![Line::from("assistant")], true)) as Arc, @@ -430,6 +575,8 @@ mod tests { as Arc, Arc::new(UserHistoryCell { message: "first".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), }) as Arc, Arc::new(AgentMessageCell::new(vec![Line::from("after")], false)) as Arc, @@ -458,11 +605,15 @@ mod tests { as Arc, Arc::new(UserHistoryCell { message: "first".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), }) as Arc, Arc::new(AgentMessageCell::new(vec![Line::from("between")], false)) as Arc, Arc::new(UserHistoryCell { message: "second".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), }) as Arc, Arc::new(AgentMessageCell::new(vec![Line::from("tail")], false)) as Arc, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 6ce5d2cae5..0b2617416d 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -14,6 +14,7 @@ use codex_common::approval_presets::ApprovalPreset; use codex_core::protocol::Event; use codex_core::protocol::RateLimitSnapshot; use codex_file_search::FileMatch; +use codex_protocol::ThreadId; use codex_protocol::openai_models::ModelPreset; use crate::bottom_pane::ApprovalRequest; @@ -22,6 +23,7 @@ use crate::history_cell::HistoryCell; use codex_core::features::Feature; use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; +use codex_protocol::config_types::CollaborationMode; use codex_protocol::openai_models::ReasoningEffort; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -41,6 +43,10 @@ pub(crate) enum WindowsSandboxFallbackReason { #[derive(Debug)] pub(crate) enum AppEvent { CodexEvent(Event), + ExternalApprovalRequest { + thread_id: ThreadId, + event: Event, + }, /// Start a new session. NewSession, @@ -48,8 +54,8 @@ pub(crate) enum AppEvent { /// Open the resume picker inside the running TUI session. OpenResumePicker, - /// Open the fork picker inside the running TUI session. - OpenForkPicker, + /// Fork the current session into a new thread. + ForkCurrentSession, /// Request to exit the application. /// @@ -97,6 +103,9 @@ pub(crate) enum AppEvent { /// Update the current model slug in the running app and widget. UpdateModel(String), + /// Update the current collaboration mode in the running app and widget. + UpdateCollaborationMode(CollaborationMode), + /// Persist the selected model and reasoning effort to the appropriate config. PersistModelSelection { model: String, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 343403704e..d54e7005eb 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -69,7 +69,6 @@ use ratatui::layout::Constraint; use ratatui::layout::Layout; use ratatui::layout::Margin; use ratatui::layout::Rect; -use ratatui::style::Style; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; @@ -80,12 +79,15 @@ use ratatui::widgets::WidgetRef; use super::chat_composer_history::ChatComposerHistory; use super::command_popup::CommandItem; use super::command_popup::CommandPopup; +use super::command_popup::CommandPopupFlags; use super::file_search_popup::FileSearchPopup; use super::footer::FooterMode; use super::footer::FooterProps; use super::footer::esc_hint_mode; use super::footer::footer_height; +use super::footer::inset_footer_hint_area; use super::footer::render_footer; +use super::footer::render_footer_hint_items; use super::footer::reset_mode_after_activity; use super::footer::toggle_shortcut_mode; use super::paste_burst::CharDecision; @@ -108,9 +110,12 @@ use codex_common::fuzzy_match::fuzzy_match; use codex_protocol::custom_prompts::CustomPrompt; use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; use codex_protocol::models::local_image_label_text; +use codex_protocol::user_input::ByteRange; +use codex_protocol::user_input::TextElement; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::LocalImageAttachment; use crate::bottom_pane::textarea::TextArea; use crate::bottom_pane::textarea::TextAreaState; use crate::clipboard_paste::normalize_pasted_path; @@ -122,6 +127,7 @@ use codex_file_search::FileMatch; use std::cell::RefCell; use std::collections::HashMap; use std::collections::HashSet; +use std::collections::VecDeque; use std::path::PathBuf; use std::time::Duration; use std::time::Instant; @@ -140,8 +146,14 @@ const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000; /// Result returned when the user interacts with the text area. #[derive(Debug, PartialEq)] pub enum InputResult { - Submitted(String), - Queued(String), + Submitted { + text: String, + text_elements: Vec, + }, + Queued { + text: String, + text_elements: Vec, + }, Command(SlashCommand), CommandWithArgs(SlashCommand, String), None, @@ -191,12 +203,20 @@ pub(crate) struct ChatComposer { custom_prompts: Vec, footer_mode: FooterMode, footer_hint_override: Option>, + footer_flash: Option, context_window_percent: Option, context_window_used_tokens: Option, skills: Option>, dismissed_skill_popup_token: Option, /// When enabled, `Enter` submits immediately and `Tab` requests queuing behavior. steer_enabled: bool, + collaboration_modes_enabled: bool, +} + +#[derive(Clone, Debug)] +struct FooterFlash { + line: Line<'static>, + expires_at: Instant, } /// Popup state – at most one can be visible at any time. @@ -244,11 +264,13 @@ impl ChatComposer { custom_prompts: Vec::new(), footer_mode: FooterMode::ShortcutSummary, footer_hint_override: None, + footer_flash: None, context_window_percent: None, context_window_used_tokens: None, skills: None, dismissed_skill_popup_token: None, steer_enabled: false, + collaboration_modes_enabled: false, }; // Apply configuration via the setter to keep side-effects centralized. this.set_disable_paste_burst(disable_paste_burst); @@ -269,6 +291,10 @@ impl ChatComposer { self.steer_enabled = enabled; } + pub fn set_collaboration_modes_enabled(&mut self, enabled: bool) { + self.collaboration_modes_enabled = enabled; + } + fn layout_areas(&self, area: Rect) -> [Rect; 3] { let footer_props = self.footer_props(); let footer_hint_height = self @@ -323,7 +349,8 @@ impl ChatComposer { let Some(text) = self.history.on_entry_response(log_id, offset, entry) else { return false; }; - self.set_text_content(text); + // Composer history (↑/↓) stores plain text only; no UI element ranges/attachments to restore here. + self.set_text_content(text, Vec::new(), Vec::new()); true } @@ -346,6 +373,7 @@ impl ChatComposer { /// 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 pasted = pasted.replace("\r\n", "\n").replace('\r', "\n"); let char_count = pasted.chars().count(); if char_count > LARGE_PASTE_CHAR_THRESHOLD { let placeholder = self.next_large_paste_placeholder(char_count); @@ -446,7 +474,7 @@ impl ChatComposer { self.attached_images = kept_images; // Rebuild textarea so placeholders become elements again. - self.textarea.set_text(""); + self.textarea.set_text_clearing_elements(""); let mut remaining: HashMap<&str, usize> = HashMap::new(); for img in &self.attached_images { *remaining.entry(img.placeholder.as_str()).or_insert(0) += 1; @@ -499,13 +527,46 @@ impl ChatComposer { self.footer_hint_override = items; } + pub(crate) fn show_footer_flash(&mut self, line: Line<'static>, duration: Duration) { + let expires_at = Instant::now() + .checked_add(duration) + .unwrap_or_else(Instant::now); + self.footer_flash = Some(FooterFlash { line, expires_at }); + } + + pub(crate) fn footer_flash_visible(&self) -> bool { + self.footer_flash + .as_ref() + .is_some_and(|flash| Instant::now() < flash.expires_at) + } + /// Replace the entire composer content with `text` and reset cursor. - pub(crate) fn set_text_content(&mut self, text: String) { + /// This clears any pending paste payloads. + pub(crate) fn set_text_content( + &mut self, + text: String, + text_elements: Vec, + local_image_paths: Vec, + ) { // Clear any existing content, placeholders, and attachments first. - self.textarea.set_text(""); + self.textarea.set_text_clearing_elements(""); self.pending_pastes.clear(); self.attached_images.clear(); - self.textarea.set_text(&text); + + self.textarea.set_text_with_elements(&text, &text_elements); + + let image_placeholders: HashSet = text_elements + .iter() + .filter_map(|elem| elem.placeholder(&text).map(str::to_string)) + .collect(); + for (idx, path) in local_image_paths.into_iter().enumerate() { + let placeholder = local_image_label_text(idx + 1); + if image_placeholders.contains(&placeholder) { + self.attached_images + .push(AttachedImage { placeholder, path }); + } + } + self.textarea.set_cursor(0); self.sync_popups(); } @@ -515,7 +576,7 @@ impl ChatComposer { return None; } let previous = self.current_text(); - self.set_text_content(String::new()); + self.set_text_content(String::new(), Vec::new(), Vec::new()); self.history.reset_navigation(); self.history.record_local_submission(&previous); Some(previous) @@ -526,6 +587,28 @@ impl ChatComposer { self.textarea.text().to_string() } + pub(crate) fn text_elements(&self) -> Vec { + self.textarea.text_elements() + } + + #[cfg(test)] + pub(crate) fn local_image_paths(&self) -> Vec { + self.attached_images + .iter() + .map(|img| img.path.clone()) + .collect() + } + + pub(crate) fn local_images(&self) -> Vec { + self.attached_images + .iter() + .map(|img| LocalImageAttachment { + placeholder: img.placeholder.clone(), + path: img.path.clone(), + }) + .collect() + } + /// 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; @@ -537,11 +620,23 @@ impl ChatComposer { .push(AttachedImage { placeholder, path }); } + #[cfg(test)] pub fn take_recent_submission_images(&mut self) -> Vec { let images = std::mem::take(&mut self.attached_images); images.into_iter().map(|img| img.path).collect() } + pub fn take_recent_submission_images_with_placeholders(&mut self) -> Vec { + let images = std::mem::take(&mut self.attached_images); + images + .into_iter() + .map(|img| LocalImageAttachment { + placeholder: img.placeholder, + path: 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: @@ -720,7 +815,7 @@ impl ChatComposer { match sel { CommandItem::Builtin(cmd) => { if cmd == SlashCommand::Skills { - self.textarea.set_text(""); + self.textarea.set_text_clearing_elements(""); return (InputResult::Command(cmd), true); } @@ -728,7 +823,8 @@ impl ChatComposer { .trim_start() .starts_with(&format!("/{}", cmd.command())); if !starts_with_cmd { - self.textarea.set_text(&format!("/{} ", cmd.command())); + self.textarea + .set_text_clearing_elements(&format!("/{} ", cmd.command())); } if !self.textarea.text().is_empty() { cursor_target = Some(self.textarea.text().len()); @@ -743,7 +839,8 @@ impl ChatComposer { ) { PromptSelectionAction::Insert { text, cursor } => { let target = cursor.unwrap_or(text.len()); - self.textarea.set_text(&text); + // Inserted prompt text is plain input; discard any elements. + self.textarea.set_text_clearing_elements(&text); cursor_target = Some(target); } PromptSelectionAction::Submit { .. } => {} @@ -772,14 +869,21 @@ impl ChatComposer { && let Some(expanded) = expand_if_numeric_with_positional_args(prompt, first_line) { - self.textarea.set_text(""); - return (InputResult::Submitted(expanded), true); + self.textarea.set_text_clearing_elements(""); + return ( + InputResult::Submitted { + text: expanded, + // Expanded prompt is plain text; no UI element ranges to preserve. + text_elements: Vec::new(), + }, + true, + ); } if let Some(sel) = popup.selected_item() { match sel { CommandItem::Builtin(cmd) => { - self.textarea.set_text(""); + self.textarea.set_text_clearing_elements(""); return (InputResult::Command(cmd), true); } CommandItem::UserPrompt(idx) => { @@ -790,12 +894,20 @@ impl ChatComposer { PromptSelectionMode::Submit, ) { PromptSelectionAction::Submit { text } => { - self.textarea.set_text(""); - return (InputResult::Submitted(text), true); + self.textarea.set_text_clearing_elements(""); + return ( + InputResult::Submitted { + text, + // Submitting a slash/custom prompt generates plain text, so there are no UI element ranges. + text_elements: Vec::new(), + }, + true, + ); } PromptSelectionAction::Insert { text, cursor } => { let target = cursor.unwrap_or(text.len()); - self.textarea.set_text(&text); + // Inserted prompt text is plain input; discard any elements. + self.textarea.set_text_clearing_elements(&text); self.textarea.set_cursor(target); return (InputResult::None, true); } @@ -1098,6 +1210,111 @@ impl ChatComposer { lower.ends_with(".png") || lower.ends_with(".jpg") || lower.ends_with(".jpeg") } + fn trim_text_elements( + original: &str, + trimmed: &str, + elements: Vec, + ) -> Vec { + if trimmed.is_empty() || elements.is_empty() { + return Vec::new(); + } + let trimmed_start = original.len().saturating_sub(original.trim_start().len()); + let trimmed_end = trimmed_start.saturating_add(trimmed.len()); + + elements + .into_iter() + .filter_map(|elem| { + let start = elem.byte_range.start; + let end = elem.byte_range.end; + if end <= trimmed_start || start >= trimmed_end { + return None; + } + let new_start = start.saturating_sub(trimmed_start); + let new_end = end.saturating_sub(trimmed_start).min(trimmed.len()); + if new_start >= new_end { + return None; + } + let placeholder = trimmed.get(new_start..new_end).map(str::to_string); + Some(TextElement::new( + ByteRange { + start: new_start, + end: new_end, + }, + placeholder, + )) + }) + .collect() + } + + /// Expand large-paste placeholders using element ranges and rebuild other element spans. + fn expand_pending_pastes( + text: &str, + mut elements: Vec, + pending_pastes: &[(String, String)], + ) -> (String, Vec) { + if pending_pastes.is_empty() || elements.is_empty() { + return (text.to_string(), elements); + } + + // Stage 1: index pending paste payloads by placeholder for deterministic replacements. + let mut pending_by_placeholder: HashMap<&str, VecDeque<&str>> = HashMap::new(); + for (placeholder, actual) in pending_pastes { + pending_by_placeholder + .entry(placeholder.as_str()) + .or_default() + .push_back(actual.as_str()); + } + + // Stage 2: walk elements in order and rebuild text/spans in a single pass. + elements.sort_by_key(|elem| elem.byte_range.start); + + let mut rebuilt = String::with_capacity(text.len()); + let mut rebuilt_elements = Vec::with_capacity(elements.len()); + let mut cursor = 0usize; + + for elem in elements { + let start = elem.byte_range.start.min(text.len()); + let end = elem.byte_range.end.min(text.len()); + if start > end { + continue; + } + if start > cursor { + rebuilt.push_str(&text[cursor..start]); + } + let elem_text = &text[start..end]; + let placeholder = elem.placeholder(text).map(str::to_string); + let replacement = placeholder + .as_deref() + .and_then(|ph| pending_by_placeholder.get_mut(ph)) + .and_then(VecDeque::pop_front); + if let Some(actual) = replacement { + // Stage 3: inline actual paste payloads and drop their placeholder elements. + rebuilt.push_str(actual); + } else { + // Stage 4: keep non-paste elements, updating their byte ranges for the new text. + let new_start = rebuilt.len(); + rebuilt.push_str(elem_text); + let new_end = rebuilt.len(); + let placeholder = placeholder.or_else(|| Some(elem_text.to_string())); + rebuilt_elements.push(TextElement::new( + ByteRange { + start: new_start, + end: new_end, + }, + placeholder, + )); + } + cursor = end; + } + + // Stage 5: append any trailing text that followed the last element. + if cursor < text.len() { + rebuilt.push_str(&text[cursor..]); + } + + (rebuilt, rebuilt_elements) + } + fn skills_enabled(&self) -> bool { self.skills.as_ref().is_some_and(|s| !s.is_empty()) } @@ -1272,7 +1489,8 @@ impl ChatComposer { new_text.push(' '); new_text.push_str(&text[end_idx..]); - self.textarea.set_text(&new_text); + // Path replacement is plain text; rebuild without carrying elements. + self.textarea.set_text_clearing_elements(&new_text); let new_cursor = start_idx.saturating_add(inserted.len()).saturating_add(1); self.textarea.set_cursor(new_cursor); } @@ -1307,52 +1525,49 @@ impl ChatComposer { new_text.push(' '); new_text.push_str(&text[end_idx..]); - self.textarea.set_text(&new_text); + // Skill insertion rebuilds plain text, so drop existing elements. + self.textarea.set_text_clearing_elements(&new_text); let new_cursor = start_idx.saturating_add(inserted.len()).saturating_add(1); 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(); - } - + /// On success, clears pending paste payloads because placeholders have been expanded. + fn prepare_submission_text(&mut self) -> Option<(String, Vec)> { let mut text = self.textarea.text().to_string(); let original_input = text.clone(); + let original_text_elements = self.textarea.text_elements(); + let original_local_image_paths = self + .attached_images + .iter() + .map(|img| img.path.clone()) + .collect::>(); + let original_pending_pastes = self.pending_pastes.clone(); + let mut text_elements = original_text_elements.clone(); let input_starts_with_space = original_input.starts_with(' '); - self.textarea.set_text(""); + self.textarea.set_text_clearing_elements(""); - // Replace all pending pastes in the text - for (placeholder, actual) in &self.pending_pastes { - if text.contains(placeholder) { - text = text.replace(placeholder, actual); - } + if !self.pending_pastes.is_empty() { + // Expand placeholders so element byte ranges stay aligned. + let (expanded, expanded_elements) = + Self::expand_pending_pastes(&text, text_elements, &self.pending_pastes); + text = expanded; + text_elements = expanded_elements; } - self.pending_pastes.clear(); + + let expanded_input = text.clone(); // If there is neither text nor attachments, suppress submission entirely. let has_attachments = !self.attached_images.is_empty(); text = text.trim().to_string(); + text_elements = Self::trim_text_elements(&expanded_input, &text, text_elements); 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 is_builtin = + Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled) + .any(|(command_name, _)| command_name == name); let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:"); let is_known_prompt = name .strip_prefix(&prompt_prefix) @@ -1369,7 +1584,12 @@ impl ChatComposer { self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( history_cell::new_info_event(message, None), ))); - self.textarea.set_text(&original_input); + self.set_text_content( + original_input.clone(), + original_text_elements, + original_local_image_paths, + ); + self.pending_pastes.clone_from(&original_pending_pastes); self.textarea.set_cursor(original_input.len()); return None; } @@ -1382,13 +1602,21 @@ impl ChatComposer { self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( history_cell::new_error_event(err.user_message()), ))); - self.textarea.set_text(&original_input); + self.set_text_content( + original_input.clone(), + original_text_elements, + original_local_image_paths, + ); + self.pending_pastes.clone_from(&original_pending_pastes); self.textarea.set_cursor(original_input.len()); return None; } }; if let Some(expanded) = expanded_prompt { text = expanded; + // Expanded prompt (e.g. custom prompt) is plain text; text elements not supported yet. + // TODO: Preserve UI element ranges through prompt expansion in a follow-up PR. + text_elements = Vec::new(); } if text.is_empty() && !has_attachments { return None; @@ -1396,7 +1624,8 @@ impl ChatComposer { if !text.is_empty() { self.history.record_local_submission(&text); } - Some(text) + self.pending_pastes.clear(); + Some((text, text_elements)) } /// Common logic for handling message submission/queuing. @@ -1445,20 +1674,44 @@ impl ChatComposer { } let original_input = self.textarea.text().to_string(); + let original_text_elements = self.textarea.text_elements(); + let original_local_image_paths = self + .attached_images + .iter() + .map(|img| img.path.clone()) + .collect::>(); + let original_pending_pastes = self.pending_pastes.clone(); if let Some(result) = self.try_dispatch_slash_command_with_args() { return (result, true); } - if let Some(text) = self.prepare_submission_text() { + if let Some((text, text_elements)) = self.prepare_submission_text() { if should_queue { - (InputResult::Queued(text), true) + ( + InputResult::Queued { + text, + text_elements, + }, + true, + ) } else { // Do not clear attached_images here; ChatWidget drains them via take_recent_submission_images(). - (InputResult::Submitted(text), true) + ( + InputResult::Submitted { + text, + text_elements, + }, + true, + ) } } else { - // Restore text if submission was suppressed - self.textarea.set_text(&original_input); + // Restore text if submission was suppressed. + self.set_text_content( + original_input, + original_text_elements, + original_local_image_paths, + ); + self.pending_pastes = original_pending_pastes; (InputResult::None, true) } } @@ -1469,14 +1722,11 @@ impl ChatComposer { 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) + && let Some((_n, cmd)) = + Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled) + .find(|(n, _)| *n == name) { - self.textarea.set_text(""); + self.textarea.set_text_clearing_elements(""); Some(InputResult::Command(cmd)) } else { None @@ -1494,12 +1744,12 @@ impl ChatComposer { 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) + && let Some((_n, cmd)) = + Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled) + .find(|(command_name, _)| *command_name == name) && cmd == SlashCommand::Review { - self.textarea.set_text(""); + self.textarea.set_text_clearing_elements(""); return Some(InputResult::CommandWithArgs(cmd, rest.to_string())); } } @@ -1555,7 +1805,7 @@ impl ChatComposer { _ => unreachable!(), }; if let Some(text) = replace_text { - self.set_text_content(text); + self.set_text_content(text, Vec::new(), Vec::new()); return (InputResult::None, true); } } @@ -1843,6 +2093,7 @@ impl ChatComposer { is_task_running: self.is_task_running, quit_shortcut_key: self.quit_shortcut_key, steer_enabled: self.steer_enabled, + collaboration_modes_enabled: self.collaboration_modes_enabled, context_window_percent: self.context_window_percent, context_window_used_tokens: self.context_window_used_tokens, } @@ -1865,6 +2116,9 @@ impl ChatComposer { } fn custom_footer_height(&self) -> Option { + if self.footer_flash_visible() { + return Some(1); + } self.footer_hint_override .as_ref() .map(|items| if items.is_empty() { 0 } else { 1 }) @@ -1948,12 +2202,9 @@ impl ChatComposer { return rest_after_name.is_empty(); } - let builtin_match = built_in_slash_commands() - .into_iter() - .filter(|(_, cmd)| { - windows_degraded_sandbox_active() || *cmd != SlashCommand::ElevateSandbox - }) - .any(|(cmd_name, _)| fuzzy_match(cmd_name, name).is_some()); + let builtin_match = + Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled) + .any(|(cmd_name, _)| fuzzy_match(cmd_name, name).is_some()); if builtin_match { return true; @@ -2006,8 +2257,14 @@ impl ChatComposer { _ => { if is_editing_slash_command_name { let skills_enabled = self.skills_enabled(); - let mut command_popup = - CommandPopup::new(self.custom_prompts.clone(), skills_enabled); + let collaboration_modes_enabled = self.collaboration_modes_enabled; + let mut command_popup = CommandPopup::new( + self.custom_prompts.clone(), + CommandPopupFlags { + skills_enabled, + collaboration_modes_enabled, + }, + ); command_popup.on_composer_text_change(first_line.to_string()); self.active_popup = ActivePopup::Command(command_popup); } @@ -2015,6 +2272,16 @@ impl ChatComposer { } } + fn built_in_slash_commands_for_input( + collaboration_modes_enabled: bool, + ) -> impl Iterator { + let allow_elevate_sandbox = windows_degraded_sandbox_active(); + built_in_slash_commands() + .into_iter() + .filter(move |(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox) + .filter(move |(_, cmd)| collaboration_modes_enabled || *cmd != SlashCommand::Collab) + } + pub(crate) fn set_custom_prompts(&mut self, prompts: Vec) { self.custom_prompts = prompts.clone(); if let ActivePopup::Command(popup) = &mut self.active_popup { @@ -2180,24 +2447,12 @@ impl Renderable for ChatComposer { } else { popup_rect }; - if let Some(items) = self.footer_hint_override.as_ref() { - if !items.is_empty() { - let mut spans = Vec::with_capacity(items.len() * 4); - for (idx, (key, label)) in items.iter().enumerate() { - spans.push(" ".into()); - spans.push(Span::styled(key.clone(), Style::default().bold())); - spans.push(format!(" {label}").into()); - if idx + 1 != items.len() { - spans.push(" ".into()); - } - } - let mut custom_rect = hint_rect; - if custom_rect.width > 2 { - custom_rect.x += 2; - custom_rect.width = custom_rect.width.saturating_sub(2); - } - Line::from(spans).render_ref(custom_rect, buf); + if self.footer_flash_visible() { + if let Some(flash) = self.footer_flash.as_ref() { + flash.line.render(inset_footer_hint_area(hint_rect), buf); } + } else if let Some(items) = self.footer_hint_override.as_ref() { + render_footer_hint_items(hint_rect, buf, items); } else { render_footer(hint_rect, buf, footer_props); } @@ -2357,6 +2612,84 @@ mod tests { ); } + #[test] + fn footer_flash_overrides_footer_hint_override() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_footer_hint_override(Some(vec![("K".to_string(), "label".to_string())])); + composer.show_footer_flash(Line::from("FLASH"), Duration::from_secs(10)); + + let area = Rect::new(0, 0, 60, 6); + let mut buf = Buffer::empty(area); + composer.render(area, &mut buf); + + let mut bottom_row = String::new(); + for x in 0..area.width { + bottom_row.push( + buf[(x, area.height - 1)] + .symbol() + .chars() + .next() + .unwrap_or(' '), + ); + } + assert!( + bottom_row.contains("FLASH"), + "expected flash content to render in footer row, saw: {bottom_row:?}", + ); + assert!( + !bottom_row.contains("K label"), + "expected flash to override hint override, saw: {bottom_row:?}", + ); + } + + #[test] + fn footer_flash_expires_and_falls_back_to_hint_override() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_footer_hint_override(Some(vec![("K".to_string(), "label".to_string())])); + composer.show_footer_flash(Line::from("FLASH"), Duration::from_secs(10)); + composer.footer_flash.as_mut().unwrap().expires_at = + Instant::now() - Duration::from_secs(1); + + let area = Rect::new(0, 0, 60, 6); + let mut buf = Buffer::empty(area); + composer.render(area, &mut buf); + + let mut bottom_row = String::new(); + for x in 0..area.width { + bottom_row.push( + buf[(x, area.height - 1)] + .symbol() + .chars() + .next() + .unwrap_or(' '), + ); + } + assert!( + bottom_row.contains("K label"), + "expected hint override to render after flash expired, saw: {bottom_row:?}", + ); + assert!( + !bottom_row.contains("FLASH"), + "expected expired flash to be hidden, saw: {bottom_row:?}", + ); + } + fn snapshot_composer_state(name: &str, enhanced_keys_supported: bool, setup: F) where F: FnOnce(&mut ChatComposer), @@ -2483,11 +2816,8 @@ mod tests { 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()); + composer.set_text_content("draft text".to_string(), Vec::new(), Vec::new()); assert_eq!(composer.clear_for_ctrl_c(), Some("draft text".to_string())); assert!(composer.is_empty()); @@ -2794,7 +3124,7 @@ mod tests { let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { - InputResult::Submitted(text) => assert_eq!(text, "1あ"), + InputResult::Submitted { text, .. } => assert_eq!(text, "1あ"), _ => panic!("expected Submitted"), } } @@ -3008,7 +3338,7 @@ mod tests { false, ); - composer.textarea.set_text("/diff"); + composer.textarea.set_text_clearing_elements("/diff"); composer.textarea.set_cursor("/diff".len()); composer .paste_burst @@ -3116,7 +3446,7 @@ mod tests { let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { - InputResult::Submitted(text) => assert_eq!(text, "hello"), + InputResult::Submitted { text, .. } => assert_eq!(text, "hello"), _ => panic!("expected Submitted"), } } @@ -3182,7 +3512,7 @@ mod tests { let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { - InputResult::Submitted(text) => assert_eq!(text, large), + InputResult::Submitted { text, .. } => assert_eq!(text, large), _ => panic!("expected Submitted"), } assert!(composer.pending_pastes.is_empty()); @@ -3455,10 +3785,10 @@ mod tests { InputResult::CommandWithArgs(_, _) => { panic!("expected command dispatch without args for '/init'") } - InputResult::Submitted(text) => { + InputResult::Submitted { text, .. } => { panic!("expected command dispatch, but composer submitted literal text: {text}") } - InputResult::Queued(_) => { + InputResult::Queued { .. } => { panic!("expected command dispatch, but composer queued literal text") } InputResult::None => panic!("expected Command result for '/init'"), @@ -3534,10 +3864,10 @@ mod tests { InputResult::CommandWithArgs(_, _) => { panic!("expected command dispatch without args for '/diff'") } - InputResult::Submitted(text) => { + InputResult::Submitted { text, .. } => { panic!("expected command dispatch after Tab completion, got literal submit: {text}") } - InputResult::Queued(_) => { + InputResult::Queued { .. } => { panic!("expected command dispatch after Tab completion, got literal queue") } InputResult::None => panic!("expected Command result for '/diff'"), @@ -3573,10 +3903,10 @@ mod tests { InputResult::CommandWithArgs(_, _) => { panic!("expected command dispatch without args for '/mention'") } - InputResult::Submitted(text) => { + InputResult::Submitted { text, .. } => { panic!("expected command dispatch, but composer submitted literal text: {text}") } - InputResult::Queued(_) => { + InputResult::Queued { .. } => { panic!("expected command dispatch, but composer queued literal text") } InputResult::None => panic!("expected Command result for '/mention'"), @@ -3661,7 +3991,7 @@ mod tests { // Submit and verify final expansion let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - if let InputResult::Submitted(text) = result { + if let InputResult::Submitted { text, .. } = result { assert_eq!(text, format!("{} and {}", test_cases[0].0, test_cases[2].0)); } else { panic!("expected Submitted"); @@ -3858,7 +4188,7 @@ mod tests { composer.textarea.text().contains(&placeholder), composer.pending_pastes.len(), ); - composer.textarea.set_text(""); + composer.textarea.set_text_clearing_elements(""); result }) .collect(); @@ -3874,7 +4204,7 @@ mod tests { // --- Image attachment tests --- #[test] - fn attach_image_and_submit_includes_image_paths() { + fn attach_image_and_submit_includes_local_image_paths() { let (tx, _rx) = unbounded_channel::(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new( @@ -3891,13 +4221,228 @@ mod tests { let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { - InputResult::Submitted(text) => assert_eq!(text, "[Image #1] hi"), + InputResult::Submitted { + text, + text_elements, + } => { + assert_eq!(text, "[Image #1] hi"); + assert_eq!(text_elements.len(), 1); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); + assert_eq!( + text_elements[0].byte_range, + ByteRange { + start: 0, + end: "[Image #1]".len() + } + ); + } _ => panic!("expected Submitted"), } let imgs = composer.take_recent_submission_images(); assert_eq!(vec![path], imgs); } + #[test] + fn set_text_content_reattaches_images_without_placeholder_metadata() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + let placeholder = local_image_label_text(1); + let text = format!("{placeholder} restored"); + let text_elements = vec![TextElement::new((0..placeholder.len()).into(), None)]; + let path = PathBuf::from("/tmp/image1.png"); + + composer.set_text_content(text, text_elements, vec![path.clone()]); + + assert_eq!(composer.local_image_paths(), vec![path]); + } + + #[test] + fn large_paste_preserves_image_text_elements_on_submit() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(true); + + let large_content = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5); + composer.handle_paste(large_content.clone()); + composer.handle_paste(" ".into()); + let path = PathBuf::from("/tmp/image_with_paste.png"); + composer.attach_image(path.clone()); + + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted { + text, + text_elements, + } => { + let expected = format!("{large_content} [Image #1]"); + assert_eq!(text, expected); + assert_eq!(text_elements.len(), 1); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); + assert_eq!( + text_elements[0].byte_range, + ByteRange { + start: large_content.len() + 1, + end: large_content.len() + 1 + "[Image #1]".len(), + } + ); + } + _ => panic!("expected Submitted"), + } + let imgs = composer.take_recent_submission_images(); + assert_eq!(vec![path], imgs); + } + + #[test] + fn large_paste_with_leading_whitespace_trims_and_shifts_elements() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(true); + + let large_content = format!(" {}", "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5)); + composer.handle_paste(large_content.clone()); + composer.handle_paste(" ".into()); + let path = PathBuf::from("/tmp/image_with_trim.png"); + composer.attach_image(path.clone()); + + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted { + text, + text_elements, + } => { + let trimmed = large_content.trim().to_string(); + assert_eq!(text, format!("{trimmed} [Image #1]")); + assert_eq!(text_elements.len(), 1); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); + assert_eq!( + text_elements[0].byte_range, + ByteRange { + start: trimmed.len() + 1, + end: trimmed.len() + 1 + "[Image #1]".len(), + } + ); + } + _ => panic!("expected Submitted"), + } + let imgs = composer.take_recent_submission_images(); + assert_eq!(vec![path], imgs); + } + + #[test] + fn pasted_crlf_normalizes_newlines_for_elements() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(true); + + let pasted = "line1\r\nline2\r\n".to_string(); + composer.handle_paste(pasted); + composer.handle_paste(" ".into()); + let path = PathBuf::from("/tmp/image_crlf.png"); + composer.attach_image(path.clone()); + + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted { + text, + text_elements, + } => { + assert_eq!(text, "line1\nline2\n [Image #1]"); + assert!(!text.contains('\r')); + assert_eq!(text_elements.len(), 1); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); + assert_eq!( + text_elements[0].byte_range, + ByteRange { + start: "line1\nline2\n ".len(), + end: "line1\nline2\n [Image #1]".len(), + } + ); + } + _ => panic!("expected Submitted"), + } + let imgs = composer.take_recent_submission_images(); + assert_eq!(vec![path], imgs); + } + + #[test] + fn suppressed_submission_restores_pending_paste_payload() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(true); + + composer.textarea.set_text_clearing_elements("/unknown "); + composer.textarea.set_cursor("/unknown ".len()); + let large_content = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5); + composer.handle_paste(large_content.clone()); + let placeholder = composer + .pending_pastes + .first() + .expect("expected pending paste") + .0 + .clone(); + + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!(result, InputResult::None)); + assert_eq!(composer.pending_pastes.len(), 1); + assert_eq!(composer.textarea.text(), format!("/unknown {placeholder}")); + + composer.textarea.set_cursor(0); + composer.textarea.insert_str(" "); + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted { + text, + text_elements, + } => { + assert_eq!(text, format!("/unknown {large_content}")); + assert!(text_elements.is_empty()); + } + _ => panic!("expected Submitted"), + } + assert!(composer.pending_pastes.is_empty()); + } + #[test] fn attach_image_without_text_submits_empty_text_and_images() { let (tx, _rx) = unbounded_channel::(); @@ -3915,7 +4460,21 @@ mod tests { let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { - InputResult::Submitted(text) => assert_eq!(text, "[Image #1]"), + InputResult::Submitted { + text, + text_elements, + } => { + assert_eq!(text, "[Image #1]"); + assert_eq!(text_elements.len(), 1); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); + assert_eq!( + text_elements[0].byte_range, + ByteRange { + start: 0, + end: "[Image #1]".len() + } + ); + } _ => panic!("expected Submitted"), } let imgs = composer.take_recent_submission_images(); @@ -4070,6 +4629,69 @@ mod tests { ); } + #[test] + fn deleting_reordered_image_one_renumbers_text_in_place() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + let path1 = PathBuf::from("/tmp/image_first.png"); + let path2 = PathBuf::from("/tmp/image_second.png"); + let placeholder1 = local_image_label_text(1); + let placeholder2 = local_image_label_text(2); + + // Placeholders can be reordered in the text buffer; deleting image #1 should renumber + // image #2 wherever it appears, not just after the cursor. + let text = format!("Test {placeholder2} test {placeholder1}"); + let start2 = text.find(&placeholder2).expect("placeholder2 present"); + let start1 = text.find(&placeholder1).expect("placeholder1 present"); + let text_elements = vec![ + TextElement::new( + ByteRange { + start: start2, + end: start2 + placeholder2.len(), + }, + Some(placeholder2), + ), + TextElement::new( + ByteRange { + start: start1, + end: start1 + placeholder1.len(), + }, + Some(placeholder1.clone()), + ), + ]; + composer.set_text_content(text, text_elements, vec![path1, path2.clone()]); + + let end1 = start1 + placeholder1.len(); + composer.textarea.set_cursor(end1); + + composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + + assert_eq!( + composer.textarea.text(), + format!("Test {placeholder1} test ") + ); + assert_eq!( + vec![AttachedImage { + path: path2, + placeholder: placeholder1 + }], + composer.attached_images, + "attachment renumbered after deletion" + ); + } + #[test] fn deleting_first_text_element_renumbers_following_text_element() { use crossterm::event::KeyCode; @@ -4167,7 +4789,10 @@ mod tests { let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!(InputResult::Submitted(prompt_text.to_string()), result); + assert!(matches!( + result, + InputResult::Submitted { text, .. } if text == prompt_text + )); assert!(composer.textarea.is_empty()); } @@ -4194,15 +4819,16 @@ mod tests { composer .textarea - .set_text("/prompts:my-prompt USER=Alice BRANCH=main"); + .set_text_clearing_elements("/prompts:my-prompt USER=Alice BRANCH=main"); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!( - InputResult::Submitted("Review Alice changes on main".to_string()), - result - ); + assert!(matches!( + result, + InputResult::Submitted { text, .. } + if text == "Review Alice changes on main" + )); assert!(composer.textarea.is_empty()); } @@ -4229,15 +4855,16 @@ mod tests { composer .textarea - .set_text("/prompts:my-prompt USER=\"Alice Smith\" BRANCH=dev-main"); + .set_text_clearing_elements("/prompts:my-prompt USER=\"Alice Smith\" BRANCH=dev-main"); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!( - InputResult::Submitted("Pair Alice Smith with dev-main".to_string()), - result - ); + assert!(matches!( + result, + InputResult::Submitted { text, .. } + if text == "Pair Alice Smith with dev-main" + )); assert!(composer.textarea.is_empty()); } @@ -4271,7 +4898,7 @@ mod tests { // Type the slash command let command_text = "/prompts:code-review "; - composer.textarea.set_text(command_text); + composer.textarea.set_text_clearing_elements(command_text); composer.textarea.set_cursor(command_text.len()); // Paste large content (>3000 chars) to trigger placeholder @@ -4294,7 +4921,7 @@ mod tests { // Verify the custom prompt was expanded with the large content as positional arg match result { - InputResult::Submitted(text) => { + InputResult::Submitted { text, .. } => { // The prompt should be expanded, with the large content replacing $1 assert_eq!( text, @@ -4327,12 +4954,12 @@ mod tests { composer .textarea - .set_text("/Users/example/project/src/main.rs"); + .set_text_clearing_elements("/Users/example/project/src/main.rs"); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - if let InputResult::Submitted(text) = result { + if let InputResult::Submitted { text, .. } = result { assert_eq!(text, "/Users/example/project/src/main.rs"); } else { panic!("expected Submitted"); @@ -4362,12 +4989,14 @@ mod tests { ); composer.set_steer_enabled(true); - composer.textarea.set_text(" /this-looks-like-a-command"); + composer + .textarea + .set_text_clearing_elements(" /this-looks-like-a-command"); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - if let InputResult::Submitted(text) = result { + if let InputResult::Submitted { text, .. } = result { assert_eq!(text, "/this-looks-like-a-command"); } else { panic!("expected Submitted"); @@ -4402,7 +5031,7 @@ mod tests { composer .textarea - .set_text("/prompts:my-prompt USER=Alice stray"); + .set_text_clearing_elements("/prompts:my-prompt USER=Alice stray"); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); @@ -4451,7 +5080,9 @@ mod tests { }]); // Provide only one of the required args - composer.textarea.set_text("/prompts:my-prompt USER=Alice"); + composer + .textarea + .set_text_clearing_elements("/prompts:my-prompt USER=Alice"); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); @@ -4516,7 +5147,10 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); let expected = "Header: foo\nArgs: foo bar\nNinth: \n".to_string(); - assert_eq!(InputResult::Submitted(expected), result); + assert!(matches!( + result, + InputResult::Submitted { text, .. } if text == expected + )); } #[test] @@ -4543,11 +5177,16 @@ mod tests { }]); // Type positional args; should submit with numeric expansion, no errors. - composer.textarea.set_text("/prompts:elegant hi"); + composer + .textarea + .set_text_clearing_elements("/prompts:elegant hi"); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!(InputResult::Submitted("Echo: hi".to_string()), result); + assert!(matches!( + result, + InputResult::Submitted { text, .. } if text == "Echo: hi" + )); assert!(composer.textarea.is_empty()); } @@ -4619,10 +5258,11 @@ mod tests { let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!( - InputResult::Submitted("Cost: $$ and first: x".to_string()), - result - ); + assert!(matches!( + result, + InputResult::Submitted { text, .. } + if text == "Cost: $$ and first: x" + )); } #[test] @@ -4659,7 +5299,10 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); let expected = "First: one two\nSecond: one two".to_string(); - assert_eq!(InputResult::Submitted(expected), result); + assert!(matches!( + result, + InputResult::Submitted { text, .. } if text == expected + )); } /// Behavior: the first fast ASCII character is held briefly to avoid flicker; if no burst @@ -4813,7 +5456,7 @@ mod tests { ); // Simulate history-like content: "/ test" - composer.set_text_content("/ test".to_string()); + composer.set_text_content("/ test".to_string(), Vec::new(), Vec::new()); // After set_text_content -> sync_popups is called; popup should NOT be Command. assert!( @@ -4843,21 +5486,21 @@ mod tests { ); // Case 1: bare "/" - composer.set_text_content("/".to_string()); + composer.set_text_content("/".to_string(), Vec::new(), Vec::new()); assert!( matches!(composer.active_popup, ActivePopup::Command(_)), "bare '/' should activate slash popup" ); // Case 2: valid prefix "/re" (matches /review, /resume, etc.) - composer.set_text_content("/re".to_string()); + composer.set_text_content("/re".to_string(), Vec::new(), Vec::new()); assert!( matches!(composer.active_popup, ActivePopup::Command(_)), "'/re' should activate slash popup via prefix match" ); // Case 3: fuzzy match "/ac" (subsequence of /compact and /feedback) - composer.set_text_content("/ac".to_string()); + composer.set_text_content("/ac".to_string(), Vec::new(), Vec::new()); assert!( matches!(composer.active_popup, ActivePopup::Command(_)), "'/ac' should activate slash popup via fuzzy match" @@ -4866,7 +5509,7 @@ mod tests { // Case 4: invalid prefix "/zzz" – still allowed to open popup if it // matches no built-in command; our current logic will not open popup. // Verify that explicitly. - composer.set_text_content("/zzz".to_string()); + composer.set_text_content("/zzz".to_string(), Vec::new(), Vec::new()); assert!( matches!(composer.active_popup, ActivePopup::None), "'/zzz' should not activate slash popup because it is not a prefix of any built-in command" @@ -5001,7 +5644,7 @@ mod tests { false, ); - composer.set_text_content("hello".to_string()); + composer.set_text_content("hello".to_string(), Vec::new(), Vec::new()); composer.set_input_enabled(false, Some("Input disabled for test.".to_string())); let (result, needs_redraw) = diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index ec4e86af03..83afdca30c 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -37,13 +37,20 @@ pub(crate) struct CommandPopup { state: ScrollState, } +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct CommandPopupFlags { + pub(crate) skills_enabled: bool, + pub(crate) collaboration_modes_enabled: bool, +} + impl CommandPopup { - pub(crate) fn new(mut prompts: Vec, skills_enabled: bool) -> Self { + pub(crate) fn new(mut prompts: Vec, flags: CommandPopupFlags) -> Self { let allow_elevate_sandbox = windows_degraded_sandbox_active(); let builtins: Vec<(&'static str, SlashCommand)> = built_in_slash_commands() .into_iter() - .filter(|(_, cmd)| skills_enabled || *cmd != SlashCommand::Skills) + .filter(|(_, cmd)| flags.skills_enabled || *cmd != SlashCommand::Skills) .filter(|(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox) + .filter(|(_, cmd)| flags.collaboration_modes_enabled || *cmd != SlashCommand::Collab) .collect(); // Exclude prompts that collide with builtin command names and sort by name. let exclude: HashSet = builtins.iter().map(|(n, _)| (*n).to_string()).collect(); @@ -113,8 +120,8 @@ impl CommandPopup { } /// Compute fuzzy-filtered matches over built-in commands and user prompts, - /// paired with optional highlight indices and score. Sorted by ascending - /// score, then by name for stability. + /// paired with optional highlight indices and score. Preserves the original + /// presentation order for built-ins and prompts. fn filtered(&self) -> Vec<(CommandItem, Option>, i32)> { let filter = self.command_filter.trim(); let mut out: Vec<(CommandItem, Option>, i32)> = Vec::new(); @@ -144,20 +151,6 @@ impl CommandPopup { out.push((CommandItem::UserPrompt(idx), Some(indices), score)); } } - // When filtering, sort by ascending score and then by name for stability. - out.sort_by(|a, b| { - a.2.cmp(&b.2).then_with(|| { - let an = match a.0 { - CommandItem::Builtin(c) => c.command(), - CommandItem::UserPrompt(i) => &self.prompts[i].name, - }; - let bn = match b.0 { - CommandItem::Builtin(c) => c.command(), - CommandItem::UserPrompt(i) => &self.prompts[i].name, - }; - an.cmp(bn) - }) - }); out } @@ -245,7 +238,7 @@ mod tests { #[test] fn filter_includes_init_when_typing_prefix() { - let mut popup = CommandPopup::new(Vec::new(), false); + let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); // Simulate the composer line starting with '/in' so the popup filters // matching commands by prefix. popup.on_composer_text_change("/in".to_string()); @@ -265,7 +258,7 @@ mod tests { #[test] fn selecting_init_by_exact_match() { - let mut popup = CommandPopup::new(Vec::new(), false); + let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); popup.on_composer_text_change("/init".to_string()); // When an exact match exists, the selected command should be that @@ -280,7 +273,7 @@ mod tests { #[test] fn model_is_first_suggestion_for_mo() { - let mut popup = CommandPopup::new(Vec::new(), false); + let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); popup.on_composer_text_change("/mo".to_string()); let matches = popup.filtered_items(); match matches.first() { @@ -292,6 +285,32 @@ mod tests { } } + #[test] + fn filtered_commands_keep_presentation_order() { + let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); + popup.on_composer_text_change("/m".to_string()); + + let cmds: Vec<&str> = popup + .filtered_items() + .into_iter() + .filter_map(|item| match item { + CommandItem::Builtin(cmd) => Some(cmd.command()), + CommandItem::UserPrompt(_) => None, + }) + .collect(); + assert_eq!( + cmds, + vec![ + "model", + "experimental", + "resume", + "compact", + "mention", + "mcp" + ] + ); + } + #[test] fn prompt_discovery_lists_custom_prompts() { let prompts = vec![ @@ -310,7 +329,7 @@ mod tests { argument_hint: None, }, ]; - let popup = CommandPopup::new(prompts, false); + let popup = CommandPopup::new(prompts, CommandPopupFlags::default()); let items = popup.filtered_items(); let mut prompt_names: Vec = items .into_iter() @@ -334,7 +353,7 @@ mod tests { description: None, argument_hint: None, }], - false, + CommandPopupFlags::default(), ); let items = popup.filtered_items(); let has_collision_prompt = items.into_iter().any(|it| match it { @@ -357,7 +376,7 @@ mod tests { description: Some("Create feature branch, commit and open draft PR.".to_string()), argument_hint: None, }], - false, + CommandPopupFlags::default(), ); let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None, 0)]); let description = rows.first().and_then(|row| row.description.as_deref()); @@ -377,7 +396,7 @@ mod tests { description: None, argument_hint: None, }], - false, + CommandPopupFlags::default(), ); let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None, 0)]); let description = rows.first().and_then(|row| row.description.as_deref()); @@ -386,7 +405,7 @@ mod tests { #[test] fn fuzzy_filter_matches_subsequence_for_ac() { - let mut popup = CommandPopup::new(Vec::new(), false); + let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); popup.on_composer_text_change("/ac".to_string()); let cmds: Vec<&str> = popup @@ -402,4 +421,40 @@ mod tests { "expected fuzzy search for '/ac' to include compact and feedback, got {cmds:?}" ); } + + #[test] + fn collab_command_hidden_when_collaboration_modes_disabled() { + let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); + popup.on_composer_text_change("/coll".to_string()); + + let cmds: Vec<&str> = popup + .filtered_items() + .into_iter() + .filter_map(|item| match item { + CommandItem::Builtin(cmd) => Some(cmd.command()), + CommandItem::UserPrompt(_) => None, + }) + .collect(); + assert!( + !cmds.contains(&"collab"), + "expected '/collab' to be hidden when collaboration modes are disabled, got {cmds:?}" + ); + } + + #[test] + fn collab_command_visible_when_collaboration_modes_enabled() { + let mut popup = CommandPopup::new( + Vec::new(), + CommandPopupFlags { + skills_enabled: false, + collaboration_modes_enabled: true, + }, + ); + popup.on_composer_text_change("/collab".to_string()); + + match popup.selected_item() { + Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "collab"), + other => panic!("expected collab to be selected for exact match, got {other:?}"), + } + } } diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 42c0392a61..5a54dd11d7 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -37,6 +37,7 @@ pub(crate) struct FooterProps { pub(crate) use_shift_enter_hint: bool, pub(crate) is_task_running: bool, pub(crate) steer_enabled: bool, + pub(crate) collaboration_modes_enabled: bool, /// Which key the user must press again to quit. /// /// This is rendered when `mode` is `FooterMode::QuitShortcutReminder`. @@ -103,6 +104,31 @@ pub(crate) fn render_footer(area: Rect, buf: &mut Buffer, props: FooterProps) { .render(area, buf); } +pub(crate) fn inset_footer_hint_area(mut area: Rect) -> Rect { + if area.width > 2 { + area.x += 2; + area.width = area.width.saturating_sub(2); + } + area +} + +pub(crate) fn render_footer_hint_items(area: Rect, buf: &mut Buffer, items: &[(String, String)]) { + if items.is_empty() { + return; + } + + let mut spans = Vec::with_capacity(items.len() * 4); + for (idx, (key, label)) in items.iter().enumerate() { + spans.push(" ".into()); + spans.push(key.clone().bold()); + spans.push(format!(" {label}").into()); + if idx + 1 != items.len() { + spans.push(" ".into()); + } + } + Line::from(spans).render(inset_footer_hint_area(area), buf); +} + fn footer_lines(props: FooterProps) -> Vec> { // Show the context indicator on the left, appended after the primary hint // (e.g., "? for shortcuts"). Keep it visible even when typing (i.e., when @@ -134,6 +160,7 @@ fn footer_lines(props: FooterProps) -> Vec> { use_shift_enter_hint: props.use_shift_enter_hint, esc_backtrack_hint: props.esc_backtrack_hint, is_wsl, + collaboration_modes_enabled: props.collaboration_modes_enabled, }; shortcut_overlay_lines(state) } @@ -158,6 +185,7 @@ struct ShortcutsState { use_shift_enter_hint: bool, esc_backtrack_hint: bool, is_wsl: bool, + collaboration_modes_enabled: bool, } fn quit_shortcut_reminder_line(key: KeyBinding) -> Line<'static> { @@ -190,6 +218,7 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { let mut edit_previous = Line::from(""); let mut quit = Line::from(""); let mut show_transcript = Line::from(""); + let mut change_mode = Line::from(""); for descriptor in SHORTCUTS { if let Some(text) = descriptor.overlay_entry(state) { @@ -204,11 +233,12 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { ShortcutId::EditPrevious => edit_previous = text, ShortcutId::Quit => quit = text, ShortcutId::ShowTranscript => show_transcript = text, + ShortcutId::ChangeMode => change_mode = text, } } } - let ordered = vec![ + let mut ordered = vec![ commands, shell_commands, newline, @@ -218,9 +248,12 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { external_editor, edit_previous, quit, - Line::from(""), - show_transcript, ]; + if change_mode.width() > 0 { + ordered.push(change_mode); + } + ordered.push(Line::from("")); + ordered.push(show_transcript); build_columns(ordered) } @@ -298,6 +331,7 @@ enum ShortcutId { EditPrevious, Quit, ShowTranscript, + ChangeMode, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -318,6 +352,7 @@ enum DisplayCondition { WhenShiftEnterHint, WhenNotShiftEnterHint, WhenUnderWSL, + WhenCollaborationModesEnabled, } impl DisplayCondition { @@ -327,6 +362,7 @@ impl DisplayCondition { DisplayCondition::WhenShiftEnterHint => state.use_shift_enter_hint, DisplayCondition::WhenNotShiftEnterHint => !state.use_shift_enter_hint, DisplayCondition::WhenUnderWSL => state.is_wsl, + DisplayCondition::WhenCollaborationModesEnabled => state.collaboration_modes_enabled, } } } @@ -469,6 +505,15 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ prefix: "", label: " to view transcript", }, + ShortcutDescriptor { + id: ShortcutId::ChangeMode, + bindings: &[ShortcutBinding { + key: key_hint::shift(KeyCode::Tab), + condition: DisplayCondition::WhenCollaborationModesEnabled, + }], + prefix: "", + label: " to change mode", + }, ]; #[cfg(test)] @@ -500,6 +545,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, @@ -514,6 +560,22 @@ mod tests { use_shift_enter_hint: true, is_task_running: false, steer_enabled: false, + collaboration_modes_enabled: false, + quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), + context_window_percent: None, + context_window_used_tokens: None, + }, + ); + + snapshot_footer( + "footer_shortcuts_collaboration_modes_enabled", + FooterProps { + mode: FooterMode::ShortcutOverlay, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: false, + steer_enabled: false, + collaboration_modes_enabled: true, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, @@ -528,6 +590,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, @@ -542,6 +605,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: true, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, @@ -556,6 +620,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, @@ -570,6 +635,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, @@ -584,6 +650,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: true, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: Some(72), context_window_used_tokens: None, @@ -598,6 +665,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: Some(123_456), @@ -612,6 +680,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: true, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, @@ -626,6 +695,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: true, steer_enabled: true, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), 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 f270145a04..ee23cad334 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -28,16 +28,24 @@ use bottom_pane_view::BottomPaneView; use codex_core::features::Features; use codex_core::skills::model::SkillMetadata; use codex_file_search::FileMatch; +use codex_protocol::user_input::TextElement; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use ratatui::buffer::Buffer; use ratatui::layout::Rect; +use ratatui::text::Line; use std::time::Duration; mod approval_overlay; pub(crate) use approval_overlay::ApprovalOverlay; pub(crate) use approval_overlay::ApprovalRequest; mod bottom_pane_view; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct LocalImageAttachment { + pub(crate) placeholder: String, + pub(crate) path: PathBuf, +} mod chat_composer; mod chat_composer_history; mod command_popup; @@ -188,6 +196,11 @@ impl BottomPane { self.composer.set_steer_enabled(enabled); } + pub fn set_collaboration_modes_enabled(&mut self, enabled: bool) { + self.composer.set_collaboration_modes_enabled(enabled); + self.request_redraw(); + } + pub fn status_widget(&self) -> Option<&StatusIndicatorWidget> { self.status.as_ref() } @@ -237,8 +250,10 @@ impl BottomPane { } else { // If a task is running and a status line is visible, allow Esc to // send an interrupt even while the composer has focus. - if matches!(key_event.code, crossterm::event::KeyCode::Esc) + // When a popup is active, prefer dismissing it over interrupting the task. + if key_event.code == KeyCode::Esc && self.is_task_running + && !self.composer.popup_active() && let Some(status) = &self.status { // Send Op::Interrupt @@ -309,8 +324,14 @@ impl BottomPane { } /// Replace the composer text with `text`. - pub(crate) fn set_composer_text(&mut self, text: String) { - self.composer.set_text_content(text); + pub(crate) fn set_composer_text( + &mut self, + text: String, + text_elements: Vec, + local_image_paths: Vec, + ) { + self.composer + .set_text_content(text, text_elements, local_image_paths); self.request_redraw(); } @@ -334,6 +355,19 @@ impl BottomPane { self.composer.current_text() } + pub(crate) fn composer_text_elements(&self) -> Vec { + self.composer.text_elements() + } + + pub(crate) fn composer_local_images(&self) -> Vec { + self.composer.local_images() + } + + #[cfg(test)] + pub(crate) fn composer_local_image_paths(&self) -> Vec { + self.composer.local_image_paths() + } + pub(crate) fn composer_text_with_pending(&self) -> String { self.composer.current_text_with_pending() } @@ -508,6 +542,23 @@ impl BottomPane { self.request_redraw(); } + pub(crate) fn flash_footer_hint(&mut self, line: Line<'static>, duration: Duration) { + self.composer.show_footer_flash(line, duration); + let frame_requester = self.frame_requester.clone(); + if let Ok(handle) = tokio::runtime::Handle::try_current() { + handle.spawn(async move { + tokio::time::sleep(duration).await; + frame_requester.schedule_frame(); + }); + } else { + std::thread::spawn(move || { + std::thread::sleep(duration); + frame_requester.schedule_frame(); + }); + } + self.request_redraw(); + } + pub(crate) fn composer_is_empty(&self) -> bool { self.composer.is_empty() } @@ -627,10 +678,18 @@ impl BottomPane { } } + #[cfg(test)] pub(crate) fn take_recent_submission_images(&mut self) -> Vec { self.composer.take_recent_submission_images() } + pub(crate) fn take_recent_submission_images_with_placeholders( + &mut self, + ) -> Vec { + self.composer + .take_recent_submission_images_with_placeholders() + } + fn as_renderable(&'_ self) -> RenderableItem<'_> { if let Some(view) = self.active_view() { RenderableItem::Borrowed(view) @@ -673,9 +732,13 @@ impl Renderable for BottomPane { mod tests { use super::*; use crate::app_event::AppEvent; + use codex_core::protocol::Op; + use codex_protocol::protocol::SkillScope; + use crossterm::event::KeyModifiers; use insta::assert_snapshot; use ratatui::buffer::Buffer; use ratatui::layout::Rect; + use std::path::PathBuf; use tokio::sync::mpsc::unbounded_channel; fn snapshot_buffer(buf: &Buffer) -> String { @@ -942,4 +1005,109 @@ mod tests { render_snapshot(&pane, area) ); } + + #[test] + fn esc_with_skill_popup_does_not_interrupt_task() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(vec![SkillMetadata { + name: "test-skill".to_string(), + description: "test skill".to_string(), + short_description: None, + interface: None, + path: PathBuf::from("test-skill"), + scope: SkillScope::User, + }]), + }); + + pane.set_task_running(true); + + // Repro: a running task + skill popup + Esc should dismiss the popup, not interrupt. + pane.insert_str("$"); + assert!( + pane.composer.popup_active(), + "expected skill popup after typing `$`" + ); + + pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + while let Ok(ev) = rx.try_recv() { + assert!( + !matches!(ev, AppEvent::CodexOp(Op::Interrupt)), + "expected Esc to not send Op::Interrupt when dismissing skill popup" + ); + } + assert!( + !pane.composer.popup_active(), + "expected Esc to dismiss skill popup" + ); + } + + #[test] + fn esc_with_slash_command_popup_does_not_interrupt_task() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + pane.set_task_running(true); + + // Repro: a running task + slash-command popup + Esc should not interrupt the task. + pane.insert_str("/"); + assert!( + pane.composer.popup_active(), + "expected command popup after typing `/`" + ); + + pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + while let Ok(ev) = rx.try_recv() { + assert!( + !matches!(ev, AppEvent::CodexOp(Op::Interrupt)), + "expected Esc to not send Op::Interrupt while command popup is active" + ); + } + assert_eq!(pane.composer_text(), "/"); + } + + #[test] + fn esc_interrupts_running_task_when_no_popup() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + pane.set_task_running(true); + + pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert!( + matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt))), + "expected Esc to send Op::Interrupt while a task is running" + ); + } } diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap new file mode 100644 index 0000000000..b6d87789ad --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/bottom_pane/footer.rs +assertion_line: 535 +expression: terminal.backend() +--- +" / for commands ! for shell commands " +" ctrl + j for newline tab to queue message " +" @ for file paths ctrl + v to paste images " +" ctrl + g to edit in external editor esc esc to edit previous message " +" ctrl + c to exit shift + tab to change mode " +" 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 903ebe9f82..3a849822b1 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -1,4 +1,6 @@ use crate::key_hint::is_altgr; +use codex_protocol::user_input::ByteRange; +use codex_protocol::user_input::TextElement as UserTextElement; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; @@ -60,10 +62,36 @@ impl TextArea { } } - pub fn set_text(&mut self, text: &str) { + /// Replace the textarea text and clear any existing text elements. + pub fn set_text_clearing_elements(&mut self, text: &str) { + self.set_text_inner(text, None); + } + + /// Replace the textarea text and set the provided text elements. + pub fn set_text_with_elements(&mut self, text: &str, elements: &[UserTextElement]) { + self.set_text_inner(text, Some(elements)); + } + + fn set_text_inner(&mut self, text: &str, elements: Option<&[UserTextElement]>) { + // Stage 1: replace the raw text and keep the cursor in a safe byte range. self.text = text.to_string(); self.cursor_pos = self.cursor_pos.clamp(0, self.text.len()); + // Stage 2: rebuild element ranges from scratch against the new text. self.elements.clear(); + if let Some(elements) = elements { + for elem in elements { + let mut start = elem.byte_range.start.min(self.text.len()); + let mut end = elem.byte_range.end.min(self.text.len()); + start = self.clamp_pos_to_char_boundary(start); + end = self.clamp_pos_to_char_boundary(end); + if start >= end { + continue; + } + self.elements.push(TextElement { range: start..end }); + } + self.elements.sort_by_key(|e| e.range.start); + } + // Stage 3: clamp the cursor and reset derived state tied to the prior content. self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos); self.wrap_cache.replace(None); self.preferred_col = None; @@ -722,6 +750,22 @@ impl TextArea { .collect() } + pub fn text_elements(&self) -> Vec { + self.elements + .iter() + .map(|e| { + let placeholder = self.text.get(e.range.clone()).map(str::to_string); + UserTextElement::new( + ByteRange { + start: e.range.start, + end: e.range.end, + }, + placeholder, + ) + }) + .collect() + } + pub fn element_payload_starting_at(&self, pos: usize) -> Option { let pos = pos.min(self.text.len()); let elem = self.elements.iter().find(|e| e.range.start == pos)?; @@ -1251,7 +1295,7 @@ mod tests { let mut t = TextArea::new(); t.insert_str("abcd"); t.set_cursor(1); - t.set_text("你"); + t.set_text_clearing_elements("你"); assert_eq!(t.cursor(), 0); t.insert_str("a"); assert_eq!(t.text(), "a你"); @@ -1933,7 +1977,7 @@ mod tests { for _ in 0..base_len { base.push_str(&rand_grapheme(&mut rng)); } - ta.set_text(&base); + ta.set_text_clearing_elements(&base); // Choose a valid char boundary for initial cursor let mut boundaries: Vec = vec![0]; boundaries.extend(ta.text().char_indices().map(|(i, _)| i).skip(1)); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 3d566356e2..8fb3dbacbe 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -92,7 +92,11 @@ use codex_core::skills::model::SkillMetadata; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; use codex_protocol::approvals::ElicitationRequestEvent; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::Settings; +use codex_protocol::models::local_image_label_text; use codex_protocol::parse_command::ParsedCommand; +use codex_protocol::user_input::TextElement; use codex_protocol::user_input::UserInput; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -128,6 +132,7 @@ use crate::bottom_pane::CancellationEvent; use crate::bottom_pane::DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED; use crate::bottom_pane::ExperimentalFeaturesView; use crate::bottom_pane::InputResult; +use crate::bottom_pane::LocalImageAttachment; use crate::bottom_pane::QUIT_SHORTCUT_TIMEOUT; use crate::bottom_pane::SelectionAction; use crate::bottom_pane::SelectionItem; @@ -136,6 +141,7 @@ use crate::bottom_pane::custom_prompt_view::CustomPromptView; use crate::bottom_pane::popup_consts::standard_popup_hint_line; use crate::clipboard_paste::paste_image_to_temp_png; use crate::collab; +use crate::collaboration_modes; use crate::diff_render::display_path_for; use crate::exec_cell::CommandOutput; use crate::exec_cell::ExecCell; @@ -345,8 +351,7 @@ pub(crate) struct ChatWidgetInit { pub(crate) config: Config, pub(crate) frame_requester: FrameRequester, pub(crate) app_event_tx: AppEventSender, - pub(crate) initial_prompt: Option, - pub(crate) initial_images: Vec, + pub(crate) initial_user_message: Option, pub(crate) enhanced_keys_supported: bool, pub(crate) auth_manager: Arc, pub(crate) models_manager: Arc, @@ -399,7 +404,12 @@ pub(crate) struct ChatWidget { /// where the overlay may briefly treat new tail content as already cached. active_cell_revision: u64, config: Config, - model: Option, + /// Stored collaboration mode with model and reasoning effort. + /// + /// When collaboration modes feature is enabled, this is initialized to the first preset. + /// When disabled, this is Custom. The model and reasoning effort are stored here instead of + /// being read from config or current_model. + stored_collaboration_mode: CollaborationMode, auth_manager: Arc, models_manager: Arc, session_header: SessionHeader, @@ -440,6 +450,7 @@ pub(crate) struct ChatWidget { // Previous status header to restore after a transient stream retry. retry_status_header: Option, thread_id: Option, + forked_from: Option, frame_requester: FrameRequester, // Whether to include the initial welcome banner on session configured show_welcome_banner: bool, @@ -506,16 +517,19 @@ pub(crate) struct ActiveCellTranscriptKey { pub(crate) animation_tick: Option, } -struct UserMessage { +pub(crate) struct UserMessage { text: String, - image_paths: Vec, + local_images: Vec, + text_elements: Vec, } impl From for UserMessage { fn from(text: String) -> Self { Self { text, - image_paths: Vec::new(), + local_images: Vec::new(), + // Plain text conversion has no UI element ranges. + text_elements: Vec::new(), } } } @@ -524,16 +538,107 @@ impl From<&str> for UserMessage { fn from(text: &str) -> Self { Self { text: text.to_string(), - image_paths: Vec::new(), + local_images: Vec::new(), + // Plain text conversion has no UI element ranges. + text_elements: Vec::new(), } } } -fn create_initial_user_message(text: String, image_paths: Vec) -> Option { - if text.is_empty() && image_paths.is_empty() { +pub(crate) fn create_initial_user_message( + text: Option, + local_image_paths: Vec, + text_elements: Vec, +) -> Option { + let text = text.unwrap_or_default(); + if text.is_empty() && local_image_paths.is_empty() { None } else { - Some(UserMessage { text, image_paths }) + let local_images = local_image_paths + .into_iter() + .enumerate() + .map(|(idx, path)| LocalImageAttachment { + placeholder: local_image_label_text(idx + 1), + path, + }) + .collect(); + Some(UserMessage { + text, + local_images, + text_elements, + }) + } +} + +// When merging multiple queued drafts (e.g., after interrupt), each draft starts numbering +// its attachments at [Image #1]. Reassign placeholder labels based on the attachment list so +// the combined local_image_paths order matches the labels, even if placeholders were moved +// in the text (e.g., [Image #2] appearing before [Image #1]). +fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize) -> UserMessage { + let UserMessage { + text, + text_elements, + local_images, + } = message; + if local_images.is_empty() { + return UserMessage { + text, + text_elements, + local_images, + }; + } + + let mut mapping: HashMap = HashMap::new(); + let mut remapped_images = Vec::new(); + for attachment in local_images { + let new_placeholder = local_image_label_text(*next_label); + *next_label += 1; + mapping.insert(attachment.placeholder.clone(), new_placeholder.clone()); + remapped_images.push(LocalImageAttachment { + placeholder: new_placeholder, + path: attachment.path, + }); + } + + let mut elements = text_elements; + elements.sort_by_key(|elem| elem.byte_range.start); + + let mut cursor = 0usize; + let mut rebuilt = String::new(); + let mut rebuilt_elements = Vec::new(); + for mut elem in elements { + let start = elem.byte_range.start.min(text.len()); + let end = elem.byte_range.end.min(text.len()); + if let Some(segment) = text.get(cursor..start) { + rebuilt.push_str(segment); + } + + let original = text.get(start..end).unwrap_or(""); + let placeholder = elem.placeholder(&text); + let replacement = placeholder + .and_then(|ph| mapping.get(ph)) + .map(String::as_str) + .unwrap_or(original); + + let elem_start = rebuilt.len(); + rebuilt.push_str(replacement); + let elem_end = rebuilt.len(); + + if let Some(remapped) = placeholder.and_then(|ph| mapping.get(ph)) { + elem.set_placeholder(Some(remapped.clone())); + } + elem.byte_range = (elem_start..elem_end).into(); + rebuilt_elements.push(elem); + cursor = end; + } + if let Some(segment) = text.get(cursor..) { + rebuilt.push_str(segment); + } + + UserMessage { + text: rebuilt, + local_images: remapped_images, + text_elements: rebuilt_elements, } } @@ -600,16 +705,28 @@ impl ChatWidget { .set_history_metadata(event.history_log_id, event.history_entry_count); self.set_skills(None); self.thread_id = Some(event.session_id); + self.forked_from = event.forked_from_id; self.current_rollout_path = Some(event.rollout_path.clone()); let initial_messages = event.initial_messages.clone(); let model_for_header = event.model.clone(); - self.model = Some(model_for_header.clone()); self.session_header.set_model(&model_for_header); + // Only update stored collaboration settings when collaboration modes are disabled. + // When enabled, we preserve the selected variant (Plan/Pair/Execute/Custom) and its + // instructions as-is; the session configured event should not override it. + if !self.collaboration_modes_enabled() { + self.stored_collaboration_mode = self.stored_collaboration_mode.with_updates( + Some(model_for_header.clone()), + Some(event.reasoning_effort), + None, + ); + } let session_info_cell = history_cell::new_session_info( &self.config, &model_for_header, event, self.show_welcome_banner, + self.collaboration_modes_enabled(), + self.stored_collaboration_mode.clone(), ); self.apply_session_info_cell(session_info_cell); @@ -620,7 +737,7 @@ impl ChatWidget { self.submit_op(Op::ListCustomPrompts); self.submit_op(Op::ListSkills { cwds: Vec::new(), - force_reload: false, + force_reload: true, }); if let Some(user_message) = self.initial_user_message.take() { self.submit_user_message(user_message); @@ -857,7 +974,7 @@ impl ChatWidget { if high_usage && !self.rate_limit_switch_prompt_hidden() - && self.current_model() != Some(NUDGE_MODEL_SLUG) + && self.current_model() != NUDGE_MODEL_SLUG && !matches!( self.rate_limit_switch_prompt, RateLimitSwitchPromptState::Shown @@ -992,31 +1109,76 @@ impl ChatWidget { )); } - // If any messages were queued during the task, restore them into the composer. - if !self.queued_user_messages.is_empty() { - let queued_text = self - .queued_user_messages + if let Some(combined) = self.drain_queued_messages_for_restore() { + let combined_local_image_paths = combined + .local_images .iter() - .map(|m| m.text.clone()) - .collect::>() - .join("\n"); - let existing_text = self.bottom_pane.composer_text(); - let combined = if existing_text.is_empty() { - queued_text - } else if queued_text.is_empty() { - existing_text - } else { - format!("{queued_text}\n{existing_text}") - }; - self.bottom_pane.set_composer_text(combined); - // Clear the queue and update the status indicator list. - self.queued_user_messages.clear(); + .map(|img| img.path.clone()) + .collect(); + self.bottom_pane.set_composer_text( + combined.text, + combined.text_elements, + combined_local_image_paths, + ); self.refresh_queued_user_messages(); } self.request_redraw(); } + /// Merge queued drafts (plus the current composer state) into a single message for restore. + /// + /// Each queued draft numbers attachments from `[Image #1]`. When we concatenate drafts, we + /// must renumber placeholders in a stable order so the merged attachment list stays aligned + /// with the labels embedded in text. This helper drains the queue, remaps placeholders, and + /// fixes text element byte ranges as content is appended. Returns `None` when there is nothing + /// to restore. + fn drain_queued_messages_for_restore(&mut self) -> Option { + if self.queued_user_messages.is_empty() { + return None; + } + + let existing_message = UserMessage { + text: self.bottom_pane.composer_text(), + text_elements: self.bottom_pane.composer_text_elements(), + local_images: self.bottom_pane.composer_local_images(), + }; + + let mut to_merge: Vec = self.queued_user_messages.drain(..).collect(); + if !existing_message.text.is_empty() || !existing_message.local_images.is_empty() { + to_merge.push(existing_message); + } + + let mut combined = UserMessage { + text: String::new(), + text_elements: Vec::new(), + local_images: Vec::new(), + }; + let mut combined_offset = 0usize; + let mut next_image_label = 1usize; + + for (idx, message) in to_merge.into_iter().enumerate() { + if idx > 0 { + combined.text.push('\n'); + combined_offset += 1; + } + let message = remap_placeholders_for_message(message, &mut next_image_label); + let base = combined_offset; + combined.text.push_str(&message.text); + combined_offset += message.text.len(); + combined + .text_elements + .extend(message.text_elements.into_iter().map(|mut elem| { + elem.byte_range.start += base; + elem.byte_range.end += base; + elem + })); + combined.local_images.extend(message.local_images); + } + + Some(combined) + } + fn on_plan_update(&mut self, update: UpdatePlanArgs) { self.add_to_history(history_cell::new_plan_update(update)); } @@ -1075,6 +1237,9 @@ impl ChatWidget { } fn on_terminal_interaction(&mut self, ev: TerminalInteractionEvent) { + if !self.bottom_pane.is_task_running() { + return; + } self.flush_answer_stream_with_separator(); let command_display = self .unified_exec_processes @@ -1627,8 +1792,7 @@ impl ChatWidget { config, frame_requester, app_event_tx, - initial_prompt, - initial_images, + initial_user_message, enhanced_keys_supported, auth_manager, models_manager, @@ -1636,19 +1800,38 @@ impl ChatWidget { is_first_run, model, } = common; - let mut config = config; let model = model.filter(|m| !m.trim().is_empty()); + let mut config = config; config.model = model.clone(); let mut rng = rand::rng(); let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string(); let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), thread_manager); - let model_for_header = config - .model + let model_for_header = model .clone() .unwrap_or_else(|| DEFAULT_MODEL_DISPLAY_NAME.to_string()); + let stored_collaboration_mode = if config.features.enabled(Feature::CollaborationModes) { + collaboration_modes::default_mode(models_manager.as_ref()).unwrap_or_else(|| { + CollaborationMode::Custom(Settings { + model: model_for_header.clone(), + reasoning_effort: None, + developer_instructions: None, + }) + }) + } else { + CollaborationMode::Custom(Settings { + model: model_for_header.clone(), + reasoning_effort: None, + developer_instructions: None, + }) + }; + let active_cell = if model.is_none() { - Some(Self::placeholder_session_header_cell(&config)) + Some(Self::placeholder_session_header_cell( + &config, + config.features.enabled(Feature::CollaborationModes), + stored_collaboration_mode.clone(), + )) } else { None }; @@ -1670,14 +1853,11 @@ impl ChatWidget { active_cell, active_cell_revision: 0, config, - model, + stored_collaboration_mode, auth_manager, models_manager, session_header: SessionHeader::new(model_for_header), - initial_user_message: create_initial_user_message( - initial_prompt.unwrap_or_default(), - initial_images, - ), + initial_user_message, token_info: None, rate_limit_snapshot: None, plan_type: None, @@ -1699,6 +1879,7 @@ impl ChatWidget { current_status_header: String::from("Working"), retry_status_header: None, thread_id: None, + forked_from: None, queued_user_messages: VecDeque::new(), show_welcome_banner: is_first_run, suppress_session_configured_redraw: false, @@ -1719,6 +1900,9 @@ impl ChatWidget { widget .bottom_pane .set_steer_enabled(widget.config.features.enabled(Feature::Steer)); + widget.bottom_pane.set_collaboration_modes_enabled( + widget.config.features.enabled(Feature::CollaborationModes), + ); widget } @@ -1733,8 +1917,7 @@ impl ChatWidget { config, frame_requester, app_event_tx, - initial_prompt, - initial_images, + initial_user_message, enhanced_keys_supported, auth_manager, models_manager, @@ -1751,6 +1934,22 @@ impl ChatWidget { let codex_op_tx = spawn_agent_from_existing(conversation, session_configured, app_event_tx.clone()); + let stored_collaboration_mode = if config.features.enabled(Feature::CollaborationModes) { + collaboration_modes::default_mode(models_manager.as_ref()).unwrap_or_else(|| { + CollaborationMode::Custom(Settings { + model: header_model.clone(), + reasoning_effort: None, + developer_instructions: None, + }) + }) + } else { + CollaborationMode::Custom(Settings { + model: header_model.clone(), + reasoning_effort: None, + developer_instructions: None, + }) + }; + let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), @@ -1768,14 +1967,11 @@ impl ChatWidget { active_cell: None, active_cell_revision: 0, config, - model: Some(header_model.clone()), + stored_collaboration_mode, auth_manager, models_manager, session_header: SessionHeader::new(header_model), - initial_user_message: create_initial_user_message( - initial_prompt.unwrap_or_default(), - initial_images, - ), + initial_user_message, token_info: None, rate_limit_snapshot: None, plan_type: None, @@ -1797,6 +1993,7 @@ impl ChatWidget { current_status_header: String::from("Working"), retry_status_header: None, thread_id: None, + forked_from: None, queued_user_messages: VecDeque::new(), show_welcome_banner: false, suppress_session_configured_redraw: true, @@ -1817,6 +2014,9 @@ impl ChatWidget { widget .bottom_pane .set_steer_enabled(widget.config.features.enabled(Feature::Steer)); + widget.bottom_pane.set_collaboration_modes_enabled( + widget.config.features.enabled(Feature::CollaborationModes), + ); widget } @@ -1881,6 +2081,16 @@ impl ChatWidget { } match key_event { + KeyEvent { + code: KeyCode::BackTab, + kind: KeyEventKind::Press, + .. + } if self.collaboration_modes_enabled() + && !self.bottom_pane.is_task_running() + && self.bottom_pane.no_modal_or_popup_active() => + { + self.cycle_collaboration_mode(); + } KeyEvent { code: KeyCode::Up, modifiers: KeyModifiers::ALT, @@ -1889,46 +2099,64 @@ impl ChatWidget { } if !self.queued_user_messages.is_empty() => { // Prefer the most recently queued item. if let Some(user_message) = self.queued_user_messages.pop_back() { - self.bottom_pane.set_composer_text(user_message.text); + let local_image_paths = user_message + .local_images + .iter() + .map(|img| img.path.clone()) + .collect(); + self.bottom_pane.set_composer_text( + user_message.text, + user_message.text_elements, + local_image_paths, + ); self.refresh_queued_user_messages(); self.request_redraw(); } } - _ => { - match self.bottom_pane.handle_key_event(key_event) { - InputResult::Submitted(text) => { - // Enter always sends messages immediately (bypasses queue check) - // Clear any reasoning status header when submitting a new message + _ => match self.bottom_pane.handle_key_event(key_event) { + InputResult::Submitted { + text, + text_elements, + } => { + let user_message = UserMessage { + text, + local_images: self + .bottom_pane + .take_recent_submission_images_with_placeholders(), + text_elements, + }; + if self.is_session_configured() { + // Submitted is only emitted when steer is enabled (Enter sends immediately). + // Reset any reasoning header only when we are actually submitting a turn. 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(), - }; - if !self.is_session_configured() { - self.queue_user_message(user_message); - } else { - 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(), - }; + self.submit_user_message(user_message); + } else { self.queue_user_message(user_message); } - InputResult::Command(cmd) => { - self.dispatch_command(cmd); - } - InputResult::CommandWithArgs(cmd, args) => { - self.dispatch_command_with_args(cmd, args); - } - InputResult::None => {} } - } + InputResult::Queued { + text, + text_elements, + } => { + let user_message = UserMessage { + text, + local_images: self + .bottom_pane + .take_recent_submission_images_with_placeholders(), + text_elements, + }; + self.queue_user_message(user_message); + } + InputResult::Command(cmd) => { + self.dispatch_command(cmd); + } + InputResult::CommandWithArgs(cmd, args) => { + self.dispatch_command_with_args(cmd, args); + } + InputResult::None => {} + }, } } @@ -1994,7 +2222,7 @@ impl ChatWidget { self.app_event_tx.send(AppEvent::OpenResumePicker); } SlashCommand::Fork => { - self.app_event_tx.send(AppEvent::OpenForkPicker); + self.app_event_tx.send(AppEvent::ForkCurrentSession); } SlashCommand::Init => { let init_target = self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME); @@ -2018,6 +2246,11 @@ impl ChatWidget { SlashCommand::Model => { self.open_model_popup(); } + SlashCommand::Collab => { + if self.collaboration_modes_enabled() { + self.open_collaboration_modes_popup(); + } + } SlashCommand::Approvals => { self.open_approvals_popup(); } @@ -2174,6 +2407,12 @@ impl ChatWidget { let trimmed = args.trim(); match cmd { + SlashCommand::Collab => { + let _ = trimmed; + if self.collaboration_modes_enabled() { + self.open_collaboration_modes_popup(); + } + } SlashCommand::Review if !trimmed.is_empty() => { self.submit_op(Op::Review { review_request: ReviewRequest { @@ -2251,8 +2490,19 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { - let UserMessage { text, image_paths } = user_message; - if text.is_empty() && image_paths.is_empty() { + if !self.is_session_configured() { + tracing::warn!("cannot submit user message before session is configured; queueing"); + self.queued_user_messages.push_front(user_message); + self.refresh_queued_user_messages(); + return; + } + + let UserMessage { + text, + local_images, + text_elements, + } = user_message; + if text.is_empty() && local_images.is_empty() { return; } @@ -2276,15 +2526,16 @@ impl ChatWidget { return; } - for path in image_paths { - items.push(UserInput::LocalImage { path }); + for image in &local_images { + items.push(UserInput::LocalImage { + path: image.path.clone(), + }); } if !text.is_empty() { - // TODO: Thread text element ranges from the composer input. Empty keeps old behavior. items.push(UserInput::Text { text: text.clone(), - text_elements: Vec::new(), + text_elements: text_elements.clone(), }); } @@ -2298,14 +2549,23 @@ impl ChatWidget { } } - self.codex_op_tx - .send(Op::UserInput { - items, - final_output_json_schema: None, - }) - .unwrap_or_else(|e| { - tracing::error!("failed to send message: {e}"); - }); + let op = Op::UserTurn { + items, + cwd: self.config.cwd.clone(), + approval_policy: self.config.approval_policy.value(), + sandbox_policy: self.config.sandbox_policy.get().clone(), + model: self.stored_collaboration_mode.model().to_string(), + effort: self.stored_collaboration_mode.reasoning_effort(), + summary: self.config.model_reasoning_summary, + final_output_json_schema: None, + collaboration_mode: self + .collaboration_modes_enabled() + .then(|| self.stored_collaboration_mode.clone()), + }; + + self.codex_op_tx.send(op).unwrap_or_else(|e| { + tracing::error!("failed to send message: {e}"); + }); // Persist the text to cross-session message history. if !text.is_empty() { @@ -2318,7 +2578,12 @@ impl ChatWidget { // Only show the text portion in conversation history. if !text.is_empty() { - self.add_to_history(history_cell::new_user_prompt(text)); + let local_image_paths = local_images.into_iter().map(|img| img.path).collect(); + self.add_to_history(history_cell::new_user_prompt( + text, + text_elements, + local_image_paths, + )); } self.needs_final_message_separator = false; @@ -2475,7 +2740,8 @@ impl ChatWidget { | EventMsg::ItemCompleted(_) | EventMsg::AgentMessageContentDelta(_) | EventMsg::ReasoningContentDelta(_) - | EventMsg::ReasoningRawContentDelta(_) => {} + | EventMsg::ReasoningRawContentDelta(_) + | EventMsg::RequestUserInput(_) => {} } } @@ -2533,10 +2799,16 @@ impl ChatWidget { } fn on_user_message_event(&mut self, event: UserMessageEvent) { - let message = event.message.trim(); - if !message.is_empty() { - self.add_to_history(history_cell::new_user_prompt(message.to_string())); + if !event.message.trim().is_empty() { + self.add_to_history(history_cell::new_user_prompt( + event.message, + event.text_elements, + event.local_images, + )); } + + // User messages reset separator state so the next agent response doesn't add a stray break. + self.needs_final_message_separator = false; } /// Exit the UI immediately without waiting for shutdown. @@ -2629,16 +2901,21 @@ impl ChatWidget { let total_usage = token_info .map(|ti| &ti.total_token_usage) .unwrap_or(&default_usage); + let collaboration_mode = self.collaboration_mode_label(); + let reasoning_effort_override = Some(self.stored_collaboration_mode.reasoning_effort()); self.add_to_history(crate::status::new_status_output( &self.config, self.auth_manager.as_ref(), token_info, total_usage, &self.thread_id, + self.forked_from, self.rate_limit_snapshot.as_ref(), self.plan_type, Local::now(), self.model_display_name(), + collaboration_mode, + reasoning_effort_override, )); } @@ -2732,6 +3009,7 @@ impl ChatWidget { model: Some(switch_model.clone()), effort: Some(Some(default_effort)), summary: None, + collaboration_mode: None, })); tx.send(AppEvent::UpdateModel(switch_model.clone())); tx.send(AppEvent::UpdateReasoningEffort(Some(default_effort))); @@ -2861,7 +3139,7 @@ impl ChatWidget { let current_model = self.current_model(); let current_label = presets .iter() - .find(|preset| Some(preset.model.as_str()) == current_model) + .find(|preset| preset.model.as_str() == current_model) .map(|preset| preset.display_name.to_string()) .unwrap_or_else(|| self.model_display_name().to_string()); @@ -2889,7 +3167,7 @@ impl ChatWidget { SelectionItem { name: preset.display_name.clone(), description, - is_current: Some(model.as_str()) == current_model, + is_current: model.as_str() == current_model, is_default: preset.is_default, actions, dismiss_on_select: true, @@ -2959,7 +3237,7 @@ impl ChatWidget { for preset in presets.into_iter() { let description = (!preset.description.is_empty()).then_some(preset.description.to_string()); - let is_current = Some(preset.model.as_str()) == self.current_model(); + let is_current = preset.model.as_str() == self.current_model(); let single_supported_effort = preset.supported_reasoning_efforts.len() == 1; let preset_for_action = preset.clone(); let actions: Vec = vec![Box::new(move |tx| { @@ -2991,6 +3269,49 @@ impl ChatWidget { }); } + pub(crate) fn open_collaboration_modes_popup(&mut self) { + let presets = self.models_manager.list_collaboration_modes(); + if presets.is_empty() { + self.add_info_message( + "No collaboration modes are available right now.".to_string(), + None, + ); + return; + } + + let items: Vec = presets + .into_iter() + .map(|preset| { + let name = match preset { + CollaborationMode::Plan(_) => "Plan", + CollaborationMode::PairProgramming(_) => "Pair Programming", + CollaborationMode::Execute(_) => "Execute", + CollaborationMode::Custom(_) => "Custom", + }; + let is_current = + collaboration_modes::same_variant(&self.stored_collaboration_mode, &preset); + let actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::UpdateCollaborationMode(preset.clone())); + })]; + SelectionItem { + name: name.to_string(), + is_current, + actions, + dismiss_on_select: true, + ..Default::default() + } + }) + .collect(); + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Select Collaboration Mode".to_string()), + subtitle: Some("Pick a collaboration preset.".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }); + } + fn model_selection_actions( model_for_action: String, effort_for_action: Option, @@ -3006,6 +3327,7 @@ impl ChatWidget { model: Some(model_for_action.clone()), effort: Some(effort_for_action), summary: None, + collaboration_mode: None, })); tx.send(AppEvent::UpdateModel(model_for_action.clone())); tx.send(AppEvent::UpdateReasoningEffort(effort_for_action)); @@ -3085,9 +3407,9 @@ impl ChatWidget { .or(Some(default_effort)); let model_slug = preset.model.to_string(); - let is_current_model = self.current_model() == Some(preset.model.as_str()); + let is_current_model = self.current_model() == preset.model.as_str(); let highlight_choice = if is_current_model { - self.config.model_reasoning_effort + self.stored_collaboration_mode.reasoning_effort() } else { default_choice }; @@ -3177,6 +3499,7 @@ impl ChatWidget { model: Some(model.clone()), effort: Some(effort), summary: None, + collaboration_mode: None, })); self.app_event_tx.send(AppEvent::UpdateModel(model.clone())); self.app_event_tx @@ -3345,6 +3668,7 @@ impl ChatWidget { model: None, effort: None, summary: None, + collaboration_mode: None, })); tx.send(AppEvent::UpdateAskForApprovalPolicy(approval)); tx.send(AppEvent::UpdateSandboxPolicy(sandbox_clone)); @@ -3885,6 +4209,21 @@ impl ChatWidget { if feature == Feature::Steer { self.bottom_pane.set_steer_enabled(enabled); } + if feature == Feature::CollaborationModes { + self.bottom_pane.set_collaboration_modes_enabled(enabled); + let settings = match &self.stored_collaboration_mode { + CollaborationMode::Plan(settings) + | CollaborationMode::PairProgramming(settings) + | CollaborationMode::Execute(settings) + | CollaborationMode::Custom(settings) => settings.clone(), + }; + self.stored_collaboration_mode = if enabled { + collaboration_modes::default_mode(self.models_manager.as_ref()) + .unwrap_or(CollaborationMode::Custom(settings)) + } else { + CollaborationMode::Custom(settings) + }; + } } pub(crate) fn set_full_access_warning_acknowledged(&mut self, acknowledged: bool) { @@ -3910,27 +4249,110 @@ impl ChatWidget { .unwrap_or(false) } - /// Set the reasoning effort in the widget's config copy. + /// Set the reasoning effort in the stored collaboration mode. pub(crate) fn set_reasoning_effort(&mut self, effort: Option) { - self.config.model_reasoning_effort = effort; + self.stored_collaboration_mode = + self.stored_collaboration_mode + .with_updates(None, Some(effort), None); } - /// Set the model in the widget's config copy. + /// Set the model in the widget's config copy and stored collaboration mode. pub(crate) fn set_model(&mut self, model: &str) { self.session_header.set_model(model); - self.model = Some(model.to_string()); + self.stored_collaboration_mode = + self.stored_collaboration_mode + .with_updates(Some(model.to_string()), None, None); } - fn current_model(&self) -> Option<&str> { - self.model.as_deref() + pub(crate) fn current_model(&self) -> &str { + self.stored_collaboration_mode.model() + } + + #[allow(dead_code)] // Used in tests + pub(crate) fn stored_collaboration_mode(&self) -> &CollaborationMode { + &self.stored_collaboration_mode + } + + #[cfg(test)] + pub(crate) fn current_reasoning_effort(&self) -> Option { + self.stored_collaboration_mode.reasoning_effort() + } + + fn is_session_configured(&self) -> bool { + self.thread_id.is_some() + } + + fn collaboration_modes_enabled(&self) -> bool { + self.config.features.enabled(Feature::CollaborationModes) } fn model_display_name(&self) -> &str { - self.model.as_deref().unwrap_or(DEFAULT_MODEL_DISPLAY_NAME) + let model = self.current_model(); + if model.is_empty() { + DEFAULT_MODEL_DISPLAY_NAME + } else { + model + } + } + + /// Get the label for the current collaboration mode. + fn collaboration_mode_label(&self) -> Option<&'static str> { + if !self.collaboration_modes_enabled() { + return None; + } + match &self.stored_collaboration_mode { + CollaborationMode::Plan(_) => Some("Plan"), + CollaborationMode::PairProgramming(_) => Some("Pair Programming"), + CollaborationMode::Execute(_) => Some("Execute"), + CollaborationMode::Custom(_) => None, + } + } + + /// Cycle to the next collaboration mode variant (Plan -> PairProgramming -> Execute -> Plan). + fn cycle_collaboration_mode(&mut self) { + if !self.collaboration_modes_enabled() { + return; + } + + if let Some(next_mode) = collaboration_modes::next_mode( + self.models_manager.as_ref(), + &self.stored_collaboration_mode, + ) { + self.set_collaboration_mode(next_mode); + } + } + + /// Update the stored collaboration mode. + /// + /// When collaboration modes are enabled, the current mode is attached to *every* + /// submission as `Op::UserTurn { collaboration_mode: Some(...) }`. + pub(crate) fn set_collaboration_mode(&mut self, mode: CollaborationMode) { + if !self.collaboration_modes_enabled() { + return; + } + + self.stored_collaboration_mode = mode; + + let label = self.collaboration_mode_label(); + if let Some(label) = label { + let flash = Line::from(vec![ + label.bold(), + " (".dim(), + key_hint::shift(KeyCode::Tab).into(), + " to change mode)".dim(), + ]); + const FLASH_DURATION: Duration = Duration::from_secs(2); + self.bottom_pane.flash_footer_hint(flash, FLASH_DURATION); + } + self.request_redraw(); } /// Build a placeholder header cell while the session is configuring. - fn placeholder_session_header_cell(config: &Config) -> Box { + fn placeholder_session_header_cell( + config: &Config, + is_collaboration: bool, + collaboration_mode: CollaborationMode, + ) -> Box { let placeholder_style = Style::default().add_modifier(Modifier::DIM | Modifier::ITALIC); Box::new(history_cell::SessionHeaderHistoryCell::new_with_style( DEFAULT_MODEL_DISPLAY_NAME.to_string(), @@ -3938,6 +4360,8 @@ impl ChatWidget { None, config.cwd.clone(), CODEX_CLI_VERSION, + is_collaboration, + collaboration_mode, )) } @@ -4117,8 +4541,14 @@ impl ChatWidget { } /// Replace the composer content with the provided text and reset cursor. - pub(crate) fn set_composer_text(&mut self, text: String) { - self.bottom_pane.set_composer_text(text); + pub(crate) fn set_composer_text( + &mut self, + text: String, + text_elements: Vec, + local_image_paths: Vec, + ) { + self.bottom_pane + .set_composer_text(text, text_elements, local_image_paths); } pub(crate) fn show_esc_backtrack_hint(&mut self) { @@ -4331,10 +4761,6 @@ impl ChatWidget { self.thread_id } - fn is_session_configured(&self) -> bool { - self.thread_id.is_some() - } - pub(crate) fn rollout_path(&self) -> Option { self.current_rollout_path.clone() } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 40d30c8956..0610a640c7 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -8,6 +8,8 @@ use super::*; use crate::app_event::AppEvent; use crate::app_event::ExitMode; use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::LocalImageAttachment; +use crate::history_cell::UserHistoryCell; use crate::test_backend::VT100Backend; use crate::tui::FrameRequester; use assert_matches::assert_matches; @@ -59,6 +61,7 @@ use codex_core::protocol::ViewImageToolCallEvent; use codex_core::protocol::WarningEvent; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; +use codex_protocol::config_types::CollaborationMode; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::parse_command::ParsedCommand; @@ -66,6 +69,8 @@ use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; use codex_protocol::protocol::CodexErrorInfo; +use codex_protocol::user_input::TextElement; +use codex_protocol::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -131,6 +136,7 @@ async fn resumed_initial_messages_render_history() { let rollout_file = NamedTempFile::new().unwrap(); let configured = codex_core::protocol::SessionConfiguredEvent { session_id: conversation_id, + forked_from_id: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -180,6 +186,359 @@ async fn resumed_initial_messages_render_history() { ); } +#[tokio::test] +async fn replayed_user_message_preserves_text_elements_and_local_images() { + let (mut chat, mut rx, _ops) = make_chatwidget_manual(None).await; + + let placeholder = "[Image #1]"; + let message = format!("{placeholder} replayed"); + let text_elements = vec![TextElement::new( + (0..placeholder.len()).into(), + Some(placeholder.to_string()), + )]; + let local_images = vec![PathBuf::from("/tmp/replay.png")]; + + let conversation_id = ThreadId::new(); + let rollout_file = NamedTempFile::new().unwrap(); + let configured = codex_core::protocol::SessionConfiguredEvent { + session_id: conversation_id, + forked_from_id: None, + model: "test-model".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: Some(ReasoningEffortConfig::default()), + history_log_id: 0, + history_entry_count: 0, + initial_messages: Some(vec![EventMsg::UserMessage(UserMessageEvent { + message: message.clone(), + images: None, + text_elements: text_elements.clone(), + local_images: local_images.clone(), + })]), + rollout_path: rollout_file.path().to_path_buf(), + }; + + chat.handle_codex_event(Event { + id: "initial".into(), + msg: EventMsg::SessionConfigured(configured), + }); + + let mut user_cell = None; + while let Ok(ev) = rx.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = ev + && let Some(cell) = cell.as_any().downcast_ref::() + { + user_cell = Some(( + cell.message.clone(), + cell.text_elements.clone(), + cell.local_image_paths.clone(), + )); + break; + } + } + + let (stored_message, stored_elements, stored_images) = + user_cell.expect("expected a replayed user history cell"); + assert_eq!(stored_message, message); + assert_eq!(stored_elements, text_elements); + assert_eq!(stored_images, local_images); +} + +#[tokio::test] +async fn submission_preserves_text_elements_and_local_images() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + + let conversation_id = ThreadId::new(); + let rollout_file = NamedTempFile::new().unwrap(); + let configured = codex_core::protocol::SessionConfiguredEvent { + session_id: conversation_id, + forked_from_id: None, + model: "test-model".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: Some(ReasoningEffortConfig::default()), + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + rollout_path: rollout_file.path().to_path_buf(), + }; + chat.handle_codex_event(Event { + id: "initial".into(), + msg: EventMsg::SessionConfigured(configured), + }); + drain_insert_history(&mut rx); + + let placeholder = "[Image #1]"; + let text = format!("{placeholder} submit"); + let text_elements = vec![TextElement::new( + (0..placeholder.len()).into(), + Some(placeholder.to_string()), + )]; + let local_images = vec![PathBuf::from("/tmp/submitted.png")]; + + chat.bottom_pane + .set_composer_text(text.clone(), text_elements.clone(), local_images.clone()); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let items = match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => items, + other => panic!("expected Op::UserTurn, got {other:?}"), + }; + assert_eq!(items.len(), 2); + assert_eq!( + items[0], + UserInput::LocalImage { + path: local_images[0].clone() + } + ); + assert_eq!( + items[1], + UserInput::Text { + text: text.clone(), + text_elements: text_elements.clone(), + } + ); + + let mut user_cell = None; + while let Ok(ev) = rx.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = ev + && let Some(cell) = cell.as_any().downcast_ref::() + { + user_cell = Some(( + cell.message.clone(), + cell.text_elements.clone(), + cell.local_image_paths.clone(), + )); + break; + } + } + + let (stored_message, stored_elements, stored_images) = + user_cell.expect("expected submitted user history cell"); + assert_eq!(stored_message, text); + assert_eq!(stored_elements, text_elements); + assert_eq!(stored_images, local_images); +} + +#[tokio::test] +async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + + let first_placeholder = "[Image #1]"; + let first_text = format!("{first_placeholder} first"); + let first_elements = vec![TextElement::new( + (0..first_placeholder.len()).into(), + Some(first_placeholder.to_string()), + )]; + let first_images = [PathBuf::from("/tmp/first.png")]; + + let second_placeholder = "[Image #1]"; + let second_text = format!("{second_placeholder} second"); + let second_elements = vec![TextElement::new( + (0..second_placeholder.len()).into(), + Some(second_placeholder.to_string()), + )]; + let second_images = [PathBuf::from("/tmp/second.png")]; + + let existing_placeholder = "[Image #1]"; + let existing_text = format!("{existing_placeholder} existing"); + let existing_elements = vec![TextElement::new( + (0..existing_placeholder.len()).into(), + Some(existing_placeholder.to_string()), + )]; + let existing_images = vec![PathBuf::from("/tmp/existing.png")]; + + chat.queued_user_messages.push_back(UserMessage { + text: first_text, + local_images: vec![LocalImageAttachment { + placeholder: first_placeholder.to_string(), + path: first_images[0].clone(), + }], + text_elements: first_elements, + }); + chat.queued_user_messages.push_back(UserMessage { + text: second_text, + local_images: vec![LocalImageAttachment { + placeholder: second_placeholder.to_string(), + path: second_images[0].clone(), + }], + text_elements: second_elements, + }); + chat.refresh_queued_user_messages(); + + chat.bottom_pane + .set_composer_text(existing_text, existing_elements, existing_images.clone()); + + // When interrupted, queued messages are merged into the composer; image placeholders + // must be renumbered to match the combined local image list. + chat.handle_codex_event(Event { + id: "interrupt".into(), + msg: EventMsg::TurnAborted(codex_core::protocol::TurnAbortedEvent { + reason: TurnAbortReason::Interrupted, + }), + }); + + let first = "[Image #1] first".to_string(); + let second = "[Image #2] second".to_string(); + let third = "[Image #3] existing".to_string(); + let expected_text = format!("{first}\n{second}\n{third}"); + assert_eq!(chat.bottom_pane.composer_text(), expected_text); + + let first_start = 0; + let second_start = first.len() + 1; + let third_start = second_start + second.len() + 1; + let expected_elements = vec![ + TextElement::new( + (first_start..first_start + "[Image #1]".len()).into(), + Some("[Image #1]".to_string()), + ), + TextElement::new( + (second_start..second_start + "[Image #2]".len()).into(), + Some("[Image #2]".to_string()), + ), + TextElement::new( + (third_start..third_start + "[Image #3]".len()).into(), + Some("[Image #3]".to_string()), + ), + ]; + assert_eq!(chat.bottom_pane.composer_text_elements(), expected_elements); + assert_eq!( + chat.bottom_pane.composer_local_image_paths(), + vec![ + first_images[0].clone(), + second_images[0].clone(), + existing_images[0].clone(), + ] + ); +} + +#[tokio::test] +async fn remap_placeholders_uses_attachment_labels() { + let placeholder_one = "[Image #1]"; + let placeholder_two = "[Image #2]"; + let text = format!("{placeholder_two} before {placeholder_one}"); + let elements = vec![ + TextElement::new( + (0..placeholder_two.len()).into(), + Some(placeholder_two.to_string()), + ), + TextElement::new( + ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(), + Some(placeholder_one.to_string()), + ), + ]; + + let attachments = vec![ + LocalImageAttachment { + placeholder: placeholder_one.to_string(), + path: PathBuf::from("/tmp/one.png"), + }, + LocalImageAttachment { + placeholder: placeholder_two.to_string(), + path: PathBuf::from("/tmp/two.png"), + }, + ]; + let message = UserMessage { + text, + text_elements: elements, + local_images: attachments, + }; + let mut next_label = 3usize; + let remapped = remap_placeholders_for_message(message, &mut next_label); + + assert_eq!(remapped.text, "[Image #4] before [Image #3]"); + assert_eq!( + remapped.text_elements, + vec![ + TextElement::new( + (0.."[Image #4]".len()).into(), + Some("[Image #4]".to_string()), + ), + TextElement::new( + ("[Image #4] before ".len().."[Image #4] before [Image #3]".len()).into(), + Some("[Image #3]".to_string()), + ), + ] + ); + assert_eq!( + remapped.local_images, + vec![ + LocalImageAttachment { + placeholder: "[Image #3]".to_string(), + path: PathBuf::from("/tmp/one.png"), + }, + LocalImageAttachment { + placeholder: "[Image #4]".to_string(), + path: PathBuf::from("/tmp/two.png"), + }, + ] + ); +} + +#[tokio::test] +async fn remap_placeholders_uses_byte_ranges_when_placeholder_missing() { + let placeholder_one = "[Image #1]"; + let placeholder_two = "[Image #2]"; + let text = format!("{placeholder_two} before {placeholder_one}"); + let elements = vec![ + TextElement::new((0..placeholder_two.len()).into(), None), + TextElement::new( + ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(), + None, + ), + ]; + + let attachments = vec![ + LocalImageAttachment { + placeholder: placeholder_one.to_string(), + path: PathBuf::from("/tmp/one.png"), + }, + LocalImageAttachment { + placeholder: placeholder_two.to_string(), + path: PathBuf::from("/tmp/two.png"), + }, + ]; + let message = UserMessage { + text, + text_elements: elements, + local_images: attachments, + }; + let mut next_label = 3usize; + let remapped = remap_placeholders_for_message(message, &mut next_label); + + assert_eq!(remapped.text, "[Image #4] before [Image #3]"); + assert_eq!( + remapped.text_elements, + vec![ + TextElement::new( + (0.."[Image #4]".len()).into(), + Some("[Image #4]".to_string()), + ), + TextElement::new( + ("[Image #4] before ".len().."[Image #4] before [Image #3]".len()).into(), + Some("[Image #3]".to_string()), + ), + ] + ); + assert_eq!( + remapped.local_images, + vec![ + LocalImageAttachment { + placeholder: "[Image #3]".to_string(), + path: PathBuf::from("/tmp/one.png"), + }, + LocalImageAttachment { + placeholder: "[Image #4]".to_string(), + path: PathBuf::from("/tmp/two.png"), + }, + ] + ); +} + /// Entering review mode uses the hint provided by the review request. #[tokio::test] async fn entered_review_mode_uses_request_hint() { @@ -350,8 +709,7 @@ async fn helpers_are_available_and_do_not_panic() { config: cfg, frame_requester: FrameRequester::test_dummy(), app_event_tx: tx, - initial_prompt: None, - initial_images: Vec::new(), + initial_user_message: None, enhanced_keys_supported: false, auth_manager, models_manager: thread_manager.get_models_manager(), @@ -393,8 +751,27 @@ async fn make_chatwidget_manual( skills: None, }); bottom.set_steer_enabled(true); + bottom.set_collaboration_modes_enabled(cfg.features.enabled(Feature::CollaborationModes)); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); let codex_home = cfg.codex_home.clone(); + let models_manager = Arc::new(ModelsManager::new(codex_home, auth_manager.clone())); + let collaboration_modes_enabled = cfg.features.enabled(Feature::CollaborationModes); + let reasoning_effort = None; + let stored_collaboration_mode = if collaboration_modes_enabled { + collaboration_modes::default_mode(models_manager.as_ref()).unwrap_or_else(|| { + CollaborationMode::Custom(Settings { + model: resolved_model.clone(), + reasoning_effort, + developer_instructions: None, + }) + }) + } else { + CollaborationMode::Custom(Settings { + model: resolved_model.clone(), + reasoning_effort, + developer_instructions: None, + }) + }; let widget = ChatWidget { app_event_tx, codex_op_tx: op_tx, @@ -402,9 +779,9 @@ async fn make_chatwidget_manual( active_cell: None, active_cell_revision: 0, config: cfg, - model: Some(resolved_model.clone()), - auth_manager: auth_manager.clone(), - models_manager: Arc::new(ModelsManager::new(codex_home, auth_manager)), + stored_collaboration_mode, + auth_manager, + models_manager, session_header: SessionHeader::new(resolved_model), initial_user_message: None, token_info: None, @@ -428,6 +805,7 @@ async fn make_chatwidget_manual( current_status_header: String::from("Working"), retry_status_header: None, thread_id: None, + forked_from: None, frame_requester: FrameRequester::test_dummy(), show_welcome_banner: true, queued_user_messages: VecDeque::new(), @@ -447,6 +825,19 @@ async fn make_chatwidget_manual( (widget, rx, op_rx) } +// ChatWidget may emit other `Op`s (e.g. history/logging updates) on the same channel; this helper +// filters until we see a submission op. +fn next_submit_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver) -> Op { + loop { + match op_rx.try_recv() { + Ok(op @ Op::UserTurn { .. }) => return op, + Ok(_) => continue, + Err(TryRecvError::Empty) => panic!("expected a submit op but queue was empty"), + Err(TryRecvError::Disconnected) => panic!("expected submit op but channel closed"), + } + } +} + fn set_chatgpt_auth(chat: &mut ChatWidget) { chat.auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); @@ -1063,7 +1454,8 @@ async fn enqueueing_history_prompt_multiple_times_is_stable() { chat.thread_id = Some(ThreadId::new()); // Submit an initial prompt to seed history. - chat.bottom_pane.set_composer_text("repeat me".to_string()); + chat.bottom_pane + .set_composer_text("repeat me".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); // Simulate an active task so further submissions are queued. @@ -1097,7 +1489,7 @@ async fn streaming_final_answer_keeps_task_running_state() { assert!(chat.bottom_pane.status_widget().is_none()); chat.bottom_pane - .set_composer_text("queued submission".to_string()); + .set_composer_text("queued submission".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); assert_eq!(chat.queued_user_messages.len(), 1); @@ -1340,9 +1732,32 @@ async fn unified_exec_end_after_task_complete_is_suppressed() { ); } +#[tokio::test] +async fn unified_exec_interaction_after_task_complete_is_suppressed() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.on_task_started(); + chat.on_task_complete(None); + + chat.handle_codex_event(Event { + id: "call-1".to_string(), + msg: EventMsg::TerminalInteraction(TerminalInteractionEvent { + call_id: "call-1".to_string(), + process_id: "proc-1".to_string(), + stdin: "ls\n".to_string(), + }), + }); + + let cells = drain_insert_history(&mut rx); + assert!( + cells.is_empty(), + "expected unified exec interaction after task complete to be suppressed" + ); +} + #[tokio::test] async fn unified_exec_wait_status_header_updates_on_late_command_display() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.on_task_started(); chat.unified_exec_processes.push(UnifiedExecProcessSummary { key: "proc-1".to_string(), command_display: "sleep 5".to_string(), @@ -1364,6 +1779,7 @@ async fn unified_exec_wait_status_header_updates_on_late_command_display() { #[tokio::test] async fn unified_exec_waiting_multiple_empty_snapshots() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.on_task_started(); begin_unified_exec_startup(&mut chat, "call-wait-1", "proc-1", "just fix"); terminal_interaction(&mut chat, "call-wait-1a", "proc-1", ""); @@ -1391,6 +1807,7 @@ async fn unified_exec_waiting_multiple_empty_snapshots() { #[tokio::test] async fn unified_exec_empty_then_non_empty_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.on_task_started(); begin_unified_exec_startup(&mut chat, "call-wait-2", "proc-2", "just fix"); terminal_interaction(&mut chat, "call-wait-2a", "proc-2", ""); @@ -1407,6 +1824,7 @@ async fn unified_exec_empty_then_non_empty_snapshot() { #[tokio::test] async fn unified_exec_non_empty_then_empty_snapshots() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.on_task_started(); begin_unified_exec_startup(&mut chat, "call-wait-3", "proc-3", "just fix"); terminal_interaction(&mut chat, "call-wait-3a", "proc-3", "pwd\n"); @@ -1504,6 +1922,112 @@ async fn slash_init_skips_when_project_doc_exists() { ); } +#[tokio::test] +async fn collab_mode_shift_tab_cycles_only_when_enabled_and_idle() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::CollaborationModes, false); + + let initial = chat.stored_collaboration_mode.clone(); + chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); + assert_eq!(chat.stored_collaboration_mode, initial); + + chat.set_feature_enabled(Feature::CollaborationModes, true); + + chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); + assert!(matches!( + chat.stored_collaboration_mode, + CollaborationMode::Execute(_) + )); + + chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); + assert!(matches!( + chat.stored_collaboration_mode, + CollaborationMode::Plan(_) + )); + + chat.on_task_started(); + let before = chat.stored_collaboration_mode.clone(); + chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); + assert_eq!(chat.stored_collaboration_mode, before); +} + +#[tokio::test] +async fn collab_slash_command_opens_picker_and_updates_mode() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); + chat.set_feature_enabled(Feature::CollaborationModes, true); + + chat.dispatch_command(SlashCommand::Collab); + let popup = render_bottom_popup(&chat, 80); + assert!( + popup.contains("Select Collaboration Mode"), + "expected collaboration picker: {popup}" + ); + + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + let selected_mode = match rx.try_recv() { + Ok(AppEvent::UpdateCollaborationMode(mode)) => mode, + other => panic!("expected UpdateCollaborationMode event, got {other:?}"), + }; + chat.set_collaboration_mode(selected_mode); + + chat.bottom_pane + .set_composer_text("hello".to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + match next_submit_op(&mut op_rx) { + Op::UserTurn { + collaboration_mode: Some(CollaborationMode::PairProgramming(_)), + .. + } => {} + other => { + panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}") + } + } + + chat.bottom_pane + .set_composer_text("follow up".to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + match next_submit_op(&mut op_rx) { + Op::UserTurn { + collaboration_mode: Some(CollaborationMode::PairProgramming(_)), + .. + } => {} + other => { + panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}") + } + } +} + +#[tokio::test] +async fn collab_mode_defaults_to_pair_programming_when_enabled() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); + chat.set_feature_enabled(Feature::CollaborationModes, true); + + chat.bottom_pane + .set_composer_text("hello".to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + match next_submit_op(&mut op_rx) { + Op::UserTurn { + collaboration_mode: Some(CollaborationMode::PairProgramming(_)), + .. + } => {} + other => { + panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}") + } + } +} + +#[tokio::test] +async fn collab_mode_enabling_sets_pair_programming_default() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::CollaborationModes, true); + assert!(matches!( + chat.stored_collaboration_mode, + CollaborationMode::PairProgramming(_) + )); +} + #[tokio::test] async fn slash_quit_requests_exit() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -1532,12 +2056,12 @@ async fn slash_resume_opens_picker() { } #[tokio::test] -async fn slash_fork_opens_picker() { +async fn slash_fork_requests_current_fork() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; chat.dispatch_command(SlashCommand::Fork); - assert_matches!(rx.try_recv(), Ok(AppEvent::OpenForkPicker)); + assert_matches!(rx.try_recv(), Ok(AppEvent::ForkCurrentSession)); } #[tokio::test] @@ -2216,7 +2740,7 @@ async fn model_reasoning_selection_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; set_chatgpt_auth(&mut chat); - chat.config.model_reasoning_effort = Some(ReasoningEffortConfig::High); + chat.set_reasoning_effort(Some(ReasoningEffortConfig::High)); let preset = get_available_model(&chat, "gpt-5.1-codex-max"); chat.open_reasoning_popup(preset); @@ -2230,7 +2754,7 @@ async fn model_reasoning_selection_popup_extra_high_warning_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; set_chatgpt_auth(&mut chat); - chat.config.model_reasoning_effort = Some(ReasoningEffortConfig::XHigh); + chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh)); let preset = get_available_model(&chat, "gpt-5.1-codex-max"); chat.open_reasoning_popup(preset); @@ -2748,7 +3272,7 @@ async fn interrupt_prepends_queued_messages_before_existing_composer_text() { chat.bottom_pane.set_task_running(true); chat.bottom_pane - .set_composer_text("current draft".to_string()); + .set_composer_text("current draft".to_string(), Vec::new(), Vec::new()); chat.queued_user_messages .push_back(UserMessage::from("first queued".to_string())); @@ -3772,8 +4296,11 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { delta: "**Investigating rendering code**".into(), }), }); - chat.bottom_pane - .set_composer_text("Summarize recent commits".to_string()); + chat.bottom_pane.set_composer_text( + "Summarize recent commits".to_string(), + Vec::new(), + Vec::new(), + ); let width: u16 = 80; let ui_height: u16 = chat.desired_height(width); diff --git a/codex-rs/tui/src/clipboard_paste.rs b/codex-rs/tui/src/clipboard_paste.rs index 5863c728b0..4d28b365fe 100644 --- a/codex-rs/tui/src/clipboard_paste.rs +++ b/codex-rs/tui/src/clipboard_paste.rs @@ -244,9 +244,14 @@ pub fn paste_image_to_temp_png() -> Result<(PathBuf, PastedImageInfo), PasteImag /// - shell-escaped single paths (via `shlex`) pub fn normalize_pasted_path(pasted: &str) -> Option { let pasted = pasted.trim(); + let unquoted = pasted + .strip_prefix('"') + .and_then(|s| s.strip_suffix('"')) + .or_else(|| pasted.strip_prefix('\'').and_then(|s| s.strip_suffix('\''))) + .unwrap_or(pasted); // file:// URL → filesystem path - if let Ok(url) = url::Url::parse(pasted) + if let Ok(url) = url::Url::parse(unquoted) && url.scheme() == "file" { return url.to_file_path().ok(); @@ -258,38 +263,18 @@ pub fn normalize_pasted_path(pasted: &str) -> Option { // Detect unquoted Windows paths and bypass POSIX shlex which // treats backslashes as escapes (e.g., C:\Users\Alice\file.png). // Also handles UNC paths (\\server\share\path). - let looks_like_windows_path = { - // Drive letter path: C:\ or C:/ - let drive = pasted - .chars() - .next() - .map(|c| c.is_ascii_alphabetic()) - .unwrap_or(false) - && pasted.get(1..2) == Some(":") - && pasted - .get(2..3) - .map(|s| s == "\\" || s == "/") - .unwrap_or(false); - // UNC path: \\server\share - let unc = pasted.starts_with("\\\\"); - drive || unc - }; - if looks_like_windows_path { - #[cfg(target_os = "linux")] - { - if is_probably_wsl() - && let Some(converted) = convert_windows_path_to_wsl(pasted) - { - return Some(converted); - } - } - return Some(PathBuf::from(pasted)); + if let Some(path) = normalize_windows_path(unquoted) { + return Some(path); } // shell-escaped single path → unescaped let parts: Vec = shlex::Shlex::new(pasted).collect(); if parts.len() == 1 { - return parts.into_iter().next().map(PathBuf::from); + let part = parts.into_iter().next()?; + if let Some(path) = normalize_windows_path(&part) { + return Some(path); + } + return Some(PathBuf::from(part)); } None @@ -339,6 +324,36 @@ fn convert_windows_path_to_wsl(input: &str) -> Option { Some(result) } +fn normalize_windows_path(input: &str) -> Option { + // Drive letter path: C:\ or C:/ + let drive = input + .chars() + .next() + .map(|c| c.is_ascii_alphabetic()) + .unwrap_or(false) + && input.get(1..2) == Some(":") + && input + .get(2..3) + .map(|s| s == "\\" || s == "/") + .unwrap_or(false); + // UNC path: \\server\share + let unc = input.starts_with("\\\\"); + if !drive && !unc { + return None; + } + + #[cfg(target_os = "linux")] + { + if is_probably_wsl() + && let Some(converted) = convert_windows_path_to_wsl(input) + { + return Some(converted); + } + } + + Some(PathBuf::from(input)) +} + /// Infer an image format for the provided path based on its extension. pub fn pasted_image_format(path: &Path) -> EncodedImageFormat { match path @@ -438,9 +453,39 @@ mod pasted_paths_tests { #[test] fn normalize_single_quoted_windows_path() { let input = r"'C:\\Users\\Alice\\My File.jpeg'"; + let unquoted = r"C:\\Users\\Alice\\My File.jpeg"; let result = normalize_pasted_path(input).expect("should trim single quotes on windows path"); - assert_eq!(result, PathBuf::from(r"C:\\Users\\Alice\\My File.jpeg")); + #[cfg(target_os = "linux")] + let expected = if is_probably_wsl() + && let Some(converted) = convert_windows_path_to_wsl(unquoted) + { + converted + } else { + PathBuf::from(unquoted) + }; + #[cfg(not(target_os = "linux"))] + let expected = PathBuf::from(unquoted); + assert_eq!(result, expected); + } + + #[test] + fn normalize_double_quoted_windows_path() { + let input = r#""C:\\Users\\Alice\\My File.jpeg""#; + let unquoted = r"C:\\Users\\Alice\\My File.jpeg"; + let result = + normalize_pasted_path(input).expect("should trim double quotes on windows path"); + #[cfg(target_os = "linux")] + let expected = if is_probably_wsl() + && let Some(converted) = convert_windows_path_to_wsl(unquoted) + { + converted + } else { + PathBuf::from(unquoted) + }; + #[cfg(not(target_os = "linux"))] + let expected = PathBuf::from(unquoted); + assert_eq!(result, expected); } #[test] diff --git a/codex-rs/tui/src/collab.rs b/codex-rs/tui/src/collab.rs index 6b1042810c..5a1a18f63c 100644 --- a/codex-rs/tui/src/collab.rs +++ b/codex-rs/tui/src/collab.rs @@ -59,12 +59,12 @@ pub(crate) fn waiting_begin(ev: CollabWaitingBeginEvent) -> PlainHistoryCell { let CollabWaitingBeginEvent { call_id, sender_thread_id, - receiver_thread_id, + receiver_thread_ids, } = ev; let details = vec![ detail_line("call", call_id), detail_line("sender", sender_thread_id), - detail_line("receiver", receiver_thread_id), + detail_line("receiver", format!("{receiver_thread_ids:?}")), ]; collab_event("Collab wait begin", details) } @@ -73,14 +73,12 @@ pub(crate) fn waiting_end(ev: CollabWaitingEndEvent) -> PlainHistoryCell { let CollabWaitingEndEvent { call_id, sender_thread_id, - receiver_thread_id, - status, + statuses, } = ev; let details = vec![ detail_line("call", call_id), detail_line("sender", sender_thread_id), - detail_line("receiver", receiver_thread_id), - status_line(&status), + detail_line("statuses", format!("{statuses:#?}")), ]; collab_event("Collab wait end", details) } diff --git a/codex-rs/tui/src/collaboration_modes.rs b/codex-rs/tui/src/collaboration_modes.rs new file mode 100644 index 0000000000..f48ad92c91 --- /dev/null +++ b/codex-rs/tui/src/collaboration_modes.rs @@ -0,0 +1,49 @@ +use codex_core::models_manager::manager::ModelsManager; +use codex_protocol::config_types::CollaborationMode; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ModeKind { + Plan, + PairProgramming, + Execute, + Custom, +} + +fn mode_kind(mode: &CollaborationMode) -> ModeKind { + match mode { + CollaborationMode::Plan(_) => ModeKind::Plan, + CollaborationMode::PairProgramming(_) => ModeKind::PairProgramming, + CollaborationMode::Execute(_) => ModeKind::Execute, + CollaborationMode::Custom(_) => ModeKind::Custom, + } +} + +pub(crate) fn default_mode(models_manager: &ModelsManager) -> Option { + let presets = models_manager.list_collaboration_modes(); + presets + .iter() + .find(|preset| matches!(preset, CollaborationMode::PairProgramming(_))) + .cloned() + .or_else(|| presets.into_iter().next()) +} + +pub(crate) fn same_variant(a: &CollaborationMode, b: &CollaborationMode) -> bool { + mode_kind(a) == mode_kind(b) +} + +/// Cycle to the next collaboration mode preset in list order. +pub(crate) fn next_mode( + models_manager: &ModelsManager, + current: &CollaborationMode, +) -> Option { + let presets = models_manager.list_collaboration_modes(); + if presets.is_empty() { + return None; + } + let current_kind = mode_kind(current); + let next_index = presets + .iter() + .position(|preset| mode_kind(preset) == current_kind) + .map_or(0, |idx| (idx + 1) % presets.len()); + presets.get(next_index).cloned() +} diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index c060d20cd9..157ccb97a2 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -19,6 +19,7 @@ use crate::exec_cell::output_lines; use crate::exec_cell::spinner; use crate::exec_command::relativize_to_home; use crate::exec_command::strip_bash_lc_and_escape; +use crate::key_hint; use crate::live_wrap::take_prefix_by_width; use crate::markdown::append_markdown; use crate::render::line_utils::line_to_static; @@ -43,10 +44,13 @@ use codex_core::protocol::FileChange; use codex_core::protocol::McpAuthStatus; use codex_core::protocol::McpInvocation; use codex_core::protocol::SessionConfiguredEvent; +use codex_protocol::config_types::CollaborationMode; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; +use codex_protocol::user_input::TextElement; +use crossterm::event::KeyCode; use image::DynamicImage; use image::ImageReader; use mcp_types::EmbeddedResourceResource; @@ -54,6 +58,7 @@ use mcp_types::Resource; use mcp_types::ResourceLink; use mcp_types::ResourceTemplate; use ratatui::prelude::*; +use ratatui::style::Color; use ratatui::style::Modifier; use ratatui::style::Style; use ratatui::style::Styled; @@ -158,6 +163,75 @@ impl dyn HistoryCell { #[derive(Debug)] pub(crate) struct UserHistoryCell { pub message: String, + pub text_elements: Vec, + #[allow(dead_code)] + pub local_image_paths: Vec, +} + +/// Build logical lines for a user message with styled text elements. +/// +/// This preserves explicit newlines while interleaving element spans and skips +/// malformed byte ranges instead of panicking during history rendering. +fn build_user_message_lines_with_elements( + message: &str, + elements: &[TextElement], + style: Style, + element_style: Style, +) -> Vec> { + let mut elements = elements.to_vec(); + elements.sort_by_key(|e| e.byte_range.start); + let mut offset = 0usize; + let mut raw_lines: Vec> = Vec::new(); + for line_text in message.split('\n') { + let line_start = offset; + let line_end = line_start + line_text.len(); + let mut spans: Vec> = Vec::new(); + // Track how much of the line we've emitted to interleave plain and styled spans. + let mut cursor = line_start; + for elem in &elements { + let start = elem.byte_range.start.max(line_start); + let end = elem.byte_range.end.min(line_end); + if start >= end { + continue; + } + let rel_start = start - line_start; + let rel_end = end - line_start; + // Guard against malformed UTF-8 byte ranges from upstream data; skip + // invalid elements rather than panicking while rendering history. + if !line_text.is_char_boundary(rel_start) || !line_text.is_char_boundary(rel_end) { + continue; + } + let rel_cursor = cursor - line_start; + if cursor < start + && line_text.is_char_boundary(rel_cursor) + && let Some(segment) = line_text.get(rel_cursor..rel_start) + { + spans.push(Span::from(segment.to_string())); + } + if let Some(segment) = line_text.get(rel_start..rel_end) { + spans.push(Span::styled(segment.to_string(), element_style)); + cursor = end; + } + } + let rel_cursor = cursor - line_start; + if cursor < line_end + && line_text.is_char_boundary(rel_cursor) + && let Some(segment) = line_text.get(rel_cursor..) + { + spans.push(Span::from(segment.to_string())); + } + let line = if spans.is_empty() { + Line::from(line_text.to_string()).style(style) + } else { + Line::from(spans).style(style) + }; + raw_lines.push(line); + // Split on '\n' so any '\r' stays in the line; advancing by 1 accounts + // for the separator byte. + offset = line_end + 1; + } + + raw_lines } impl HistoryCell for UserHistoryCell { @@ -171,13 +245,28 @@ impl HistoryCell for UserHistoryCell { .max(1); let style = user_message_style(); + let element_style = style.fg(Color::Cyan); - let wrapped = word_wrap_lines( - self.message.lines().map(|l| Line::from(l).style(style)), - // Wrap algorithm matches textarea.rs. - RtOptions::new(usize::from(wrap_width)) - .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), - ); + let wrapped = if self.text_elements.is_empty() { + word_wrap_lines( + self.message.split('\n').map(|l| Line::from(l).style(style)), + // Wrap algorithm matches textarea.rs. + RtOptions::new(usize::from(wrap_width)) + .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), + ) + } else { + let raw_lines = build_user_message_lines_with_elements( + &self.message, + &self.text_elements, + style, + element_style, + ); + word_wrap_lines( + raw_lines, + RtOptions::new(usize::from(wrap_width)) + .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), + ) + }; lines.push(Line::from("").style(style)); lines.extend(prefix_lines(wrapped, "› ".bold().dim(), " ".into())); @@ -817,6 +906,8 @@ pub(crate) fn new_session_info( requested_model: &str, event: SessionConfiguredEvent, is_first_event: bool, + is_collaboration: bool, + collaboration_mode: CollaborationMode, ) -> SessionInfoCell { let SessionConfiguredEvent { model, @@ -829,6 +920,8 @@ pub(crate) fn new_session_info( reasoning_effort, config.cwd.clone(), CODEX_CLI_VERSION, + is_collaboration, + collaboration_mode, ); let mut parts: Vec> = vec![Box::new(header)]; @@ -886,8 +979,16 @@ pub(crate) fn new_session_info( SessionInfoCell(CompositeHistoryCell { parts }) } -pub(crate) fn new_user_prompt(message: String) -> UserHistoryCell { - UserHistoryCell { message } +pub(crate) fn new_user_prompt( + message: String, + text_elements: Vec, + local_image_paths: Vec, +) -> UserHistoryCell { + UserHistoryCell { + message, + text_elements, + local_image_paths, + } } #[derive(Debug)] @@ -897,6 +998,8 @@ pub(crate) struct SessionHeaderHistoryCell { model_style: Style, reasoning_effort: Option, directory: PathBuf, + is_collaboration: bool, + collaboration_mode: CollaborationMode, } impl SessionHeaderHistoryCell { @@ -905,6 +1008,8 @@ impl SessionHeaderHistoryCell { reasoning_effort: Option, directory: PathBuf, version: &'static str, + is_collaboration: bool, + collaboration_mode: CollaborationMode, ) -> Self { Self::new_with_style( model, @@ -912,6 +1017,8 @@ impl SessionHeaderHistoryCell { reasoning_effort, directory, version, + is_collaboration, + collaboration_mode, ) } @@ -921,6 +1028,8 @@ impl SessionHeaderHistoryCell { reasoning_effort: Option, directory: PathBuf, version: &'static str, + is_collaboration: bool, + collaboration_mode: CollaborationMode, ) -> Self { Self { version, @@ -928,6 +1037,20 @@ impl SessionHeaderHistoryCell { model_style, reasoning_effort, directory, + is_collaboration, + collaboration_mode, + } + } + + fn collaboration_mode_label(&self) -> Option<&'static str> { + if !self.is_collaboration { + return None; + } + match &self.collaboration_mode { + CollaborationMode::Plan(_) => Some("Plan"), + CollaborationMode::PairProgramming(_) => Some("Pair Programming"), + CollaborationMode::Execute(_) => Some("Execute"), + CollaborationMode::Custom(_) => None, } } @@ -988,25 +1111,49 @@ impl HistoryCell for SessionHeaderHistoryCell { const CHANGE_MODEL_HINT_COMMAND: &str = "/model"; const CHANGE_MODEL_HINT_EXPLANATION: &str = " to change"; + const CHANGE_MODE_HINT_EXPLANATION: &str = " to change mode"; const DIR_LABEL: &str = "directory:"; let label_width = DIR_LABEL.len(); - let model_label = format!( - "{model_label:> = vec![ - Span::from(format!("{model_label} ")).dim(), - Span::styled(self.model.clone(), self.model_style), - ]; - if let Some(reasoning) = reasoning_label { - model_spans.push(Span::from(" ")); - model_spans.push(Span::from(reasoning)); - } - model_spans.push(" ".dim()); - model_spans.push(CHANGE_MODEL_HINT_COMMAND.cyan()); - model_spans.push(CHANGE_MODEL_HINT_EXPLANATION.dim()); + + let model_spans: Vec> = if self.is_collaboration { + // Render collaboration mode instead of model + let collab_label = format!( + "{collab_label: = key_hint::shift(KeyCode::Tab).into(); + spans.push(shift_tab_span.cyan()); + spans.push(CHANGE_MODE_HINT_EXPLANATION.dim()); + spans + } else { + // Render model as before + let model_label = format!( + "{model_label: PlainHistoryCell { " • No MCP servers configured.".italic().into(), Line::from(vec![ " See the ".into(), - "\u{1b}]8;;https://github.com/openai/codex/blob/main/docs/config.md#mcp_servers\u{7}MCP docs\u{1b}]8;;\u{7}".underlined(), + "\u{1b}]8;;https://developers.openai.com/codex/mcp\u{7}MCP docs\u{1b}]8;;\u{7}" + .underlined(), " to configure them.".into(), ]) .style(Style::default().add_modifier(Modifier::DIM)), @@ -1733,6 +1881,8 @@ mod tests { use codex_core::config::types::McpServerConfig; use codex_core::config::types::McpServerTransportConfig; use codex_core::protocol::McpAuthStatus; + use codex_protocol::config_types::CollaborationMode; + use codex_protocol::config_types::Settings; use codex_protocol::parse_command::ParsedCommand; use dirs::home_dir; use pretty_assertions::assert_eq; @@ -2189,6 +2339,12 @@ mod tests { Some(ReasoningEffortConfig::High), std::env::temp_dir(), "test", + false, + CollaborationMode::Custom(Settings { + model: "gpt-4o".to_string(), + reasoning_effort: Some(ReasoningEffortConfig::High), + developer_instructions: None, + }), ); let lines = render_lines(&cell.display_lines(80)); @@ -2581,6 +2737,8 @@ mod tests { let msg = "one two three four five six seven"; let cell = UserHistoryCell { message: msg.to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), }; // Small width to force wrapping more clearly. Effective wrap width is width-2 due to the ▌ prefix and trailing space. diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 29bf2b1ce0..f5c2bc1e75 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -15,8 +15,10 @@ use codex_core::AuthManager; use codex_core::CodexAuth; use codex_core::INTERACTIVE_SESSION_SOURCES; use codex_core::RolloutRecorder; +use codex_core::ThreadSortKey; use codex_core::auth::enforce_login_restrictions; use codex_core::config::Config; +use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; use codex_core::config::find_codex_home; use codex_core::config::load_config_as_toml_with_cli_overrides; @@ -24,6 +26,7 @@ use codex_core::config::resolve_oss_provider; use codex_core::find_thread_path_by_id_str; use codex_core::get_platform_sandbox; use codex_core::protocol::AskForApproval; +use codex_core::read_session_meta_line; use codex_core::terminal::Multiplexer; use codex_protocol::config_types::AltScreenMode; use codex_protocol::config_types::SandboxMode; @@ -46,6 +49,7 @@ mod chatwidget; mod cli; mod clipboard_paste; mod collab; +mod collaboration_modes; mod color; pub mod custom_terminal; mod diff_render; @@ -244,7 +248,6 @@ pub async fn run_main( std::process::exit(1); } - let active_profile = config.active_profile.clone(); let log_dir = codex_core::config::log_dir(&config)?; std::fs::create_dir_all(&log_dir)?; // Open (or create) your log file, appending to it. @@ -334,16 +337,9 @@ pub async fn run_main( .with(otel_tracing_layer) .try_init(); - run_ratatui_app( - cli, - config, - overrides, - cli_kv_overrides, - active_profile, - feedback, - ) - .await - .map_err(|err| std::io::Error::other(err.to_string())) + run_ratatui_app(cli, config, overrides, cli_kv_overrides, feedback) + .await + .map_err(|err| std::io::Error::other(err.to_string())) } async fn run_ratatui_app( @@ -351,7 +347,6 @@ async fn run_ratatui_app( initial_config: Config, overrides: ConfigOverrides, cli_kv_overrides: Vec<(String, toml::Value)>, - active_profile: Option, feedback: codex_feedback::CodexFeedback, ) -> color_eyre::Result { color_eyre::install()?; @@ -402,15 +397,15 @@ async fn run_ratatui_app( initial_config.cli_auth_credentials_store_mode, ); let login_status = get_login_status(&initial_config); - let should_show_trust_screen = should_show_trust_screen(&initial_config); + let should_show_trust_screen_flag = should_show_trust_screen(&initial_config); let should_show_onboarding = - should_show_onboarding(login_status, &initial_config, should_show_trust_screen); + should_show_onboarding(login_status, &initial_config, should_show_trust_screen_flag); let config = if should_show_onboarding { let onboarding_result = run_onboarding_app( OnboardingScreenArgs { show_login_screen: should_show_login_screen(login_status, &initial_config), - show_trust_screen: should_show_trust_screen, + show_trust_screen: should_show_trust_screen_flag, login_status, auth_manager: auth_manager.clone(), config: initial_config.clone(), @@ -435,7 +430,7 @@ async fn run_ratatui_app( .map(|d| d == TrustDirectorySelection::Trust) .unwrap_or(false) { - load_config_or_exit(cli_kv_overrides, overrides).await + load_config_or_exit(cli_kv_overrides.clone(), overrides.clone()).await } else { initial_config } @@ -478,6 +473,7 @@ async fn run_ratatui_app( &config.codex_home, 1, None, + ThreadSortKey::UpdatedAt, INTERACTIVE_SESSION_SOURCES, Some(provider_filter.as_slice()), &config.model_provider_id, @@ -529,6 +525,9 @@ async fn run_ratatui_app( }; match RolloutRecorder::find_latest_thread_path( &config.codex_home, + 1, + None, + ThreadSortKey::UpdatedAt, INTERACTIVE_SESSION_SOURCES, Some(provider_filter.as_slice()), &config.model_provider_id, @@ -564,6 +563,33 @@ async fn run_ratatui_app( resume_picker::SessionSelection::StartFresh }; + let config = match &session_selection { + resume_picker::SessionSelection::Resume(path) + | resume_picker::SessionSelection::Fork(path) => { + let history_cwd = match read_session_meta_line(path).await { + Ok(meta_line) => Some(meta_line.meta.cwd), + Err(err) => { + let rollout_path = path.display().to_string(); + tracing::warn!( + %rollout_path, + %err, + "Failed to read session metadata from rollout" + ); + None + } + }; + load_config_or_exit_with_fallback_cwd( + cli_kv_overrides.clone(), + overrides.clone(), + history_cwd, + ) + .await + } + _ => config, + }; + let active_profile = config.active_profile.clone(); + let should_show_trust_screen = should_show_trust_screen(&config); + let Cli { prompt, images, @@ -665,9 +691,23 @@ fn get_login_status(config: &Config) -> LoginStatus { async fn load_config_or_exit( cli_kv_overrides: Vec<(String, toml::Value)>, overrides: ConfigOverrides, +) -> Config { + load_config_or_exit_with_fallback_cwd(cli_kv_overrides, overrides, None).await +} + +async fn load_config_or_exit_with_fallback_cwd( + cli_kv_overrides: Vec<(String, toml::Value)>, + overrides: ConfigOverrides, + fallback_cwd: Option, ) -> Config { #[allow(clippy::print_stderr)] - match Config::load_with_cli_overrides_and_harness_overrides(cli_kv_overrides, overrides).await { + match ConfigBuilder::default() + .cli_overrides(cli_kv_overrides) + .harness_overrides(overrides) + .fallback_cwd(fallback_cwd) + .build() + .await + { Ok(config) => config, Err(err) => { eprintln!("Error loading configuration: {err}"); diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index 430ef5f195..551fa9180e 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -5,7 +5,6 @@ use codex_core::auth::AuthCredentialsStoreMode; use codex_core::auth::CLIENT_ID; use codex_core::auth::login_with_api_key; use codex_core::auth::read_openai_api_key_from_env; -use codex_core::env::is_headless_environment; use codex_login::DeviceCode; use codex_login::ServerOptions; use codex_login::ShutdownHandle; @@ -59,6 +58,13 @@ pub(crate) enum SignInState { ApiKeyConfigured, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum SignInOption { + ChatGpt, + DeviceCode, + ApiKey, +} + const API_KEY_DISABLED_MESSAGE: &str = "API key login is disabled."; #[derive(Clone, Default)] @@ -96,42 +102,26 @@ impl KeyboardHandler for AuthModeWidget { match key_event.code { KeyCode::Up | KeyCode::Char('k') => { - if self.is_chatgpt_login_allowed() { - self.highlighted_mode = AuthMode::ChatGPT; - } + self.move_highlight(-1); } KeyCode::Down | KeyCode::Char('j') => { - if self.is_api_login_allowed() { - self.highlighted_mode = AuthMode::ApiKey; - } + self.move_highlight(1); } KeyCode::Char('1') => { - if self.is_chatgpt_login_allowed() { - self.start_chatgpt_login(); - } + self.select_option_by_index(0); } KeyCode::Char('2') => { - if self.is_api_login_allowed() { - self.start_api_key_entry(); - } else { - self.disallow_api_login(); - } + self.select_option_by_index(1); + } + KeyCode::Char('3') => { + self.select_option_by_index(2); } KeyCode::Enter => { let sign_in_state = { (*self.sign_in_state.read().unwrap()).clone() }; match sign_in_state { - SignInState::PickMode => match self.highlighted_mode { - AuthMode::ChatGPT if self.is_chatgpt_login_allowed() => { - self.start_chatgpt_login(); - } - AuthMode::ApiKey if self.is_api_login_allowed() => { - self.start_api_key_entry(); - } - AuthMode::ChatGPT => {} - AuthMode::ApiKey => { - self.disallow_api_login(); - } - }, + SignInState::PickMode => { + self.handle_sign_in_option(self.highlighted_mode); + } SignInState::ChatGptSuccessMessage => { *self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess; } @@ -170,7 +160,7 @@ impl KeyboardHandler for AuthModeWidget { #[derive(Clone)] pub(crate) struct AuthModeWidget { pub request_frame: FrameRequester, - pub highlighted_mode: AuthMode, + pub highlighted_mode: SignInOption, pub error: Option, pub sign_in_state: Arc>, pub codex_home: PathBuf, @@ -191,8 +181,75 @@ impl AuthModeWidget { !matches!(self.forced_login_method, Some(ForcedLoginMethod::Api)) } + fn displayed_sign_in_options(&self) -> Vec { + let mut options = vec![SignInOption::ChatGpt]; + if self.is_chatgpt_login_allowed() { + options.push(SignInOption::DeviceCode); + } + if self.is_api_login_allowed() { + options.push(SignInOption::ApiKey); + } + options + } + + fn selectable_sign_in_options(&self) -> Vec { + let mut options = Vec::new(); + if self.is_chatgpt_login_allowed() { + options.push(SignInOption::ChatGpt); + options.push(SignInOption::DeviceCode); + } + if self.is_api_login_allowed() { + options.push(SignInOption::ApiKey); + } + options + } + + fn move_highlight(&mut self, delta: isize) { + let options = self.selectable_sign_in_options(); + if options.is_empty() { + return; + } + + let current_index = options + .iter() + .position(|option| *option == self.highlighted_mode) + .unwrap_or(0); + let next_index = + (current_index as isize + delta).rem_euclid(options.len() as isize) as usize; + self.highlighted_mode = options[next_index]; + } + + fn select_option_by_index(&mut self, index: usize) { + let options = self.displayed_sign_in_options(); + if let Some(option) = options.get(index).copied() { + self.handle_sign_in_option(option); + } + } + + fn handle_sign_in_option(&mut self, option: SignInOption) { + match option { + SignInOption::ChatGpt => { + if self.is_chatgpt_login_allowed() { + self.start_chatgpt_login(); + } + } + SignInOption::DeviceCode => { + if self.is_chatgpt_login_allowed() { + self.start_device_code_login(); + } + } + SignInOption::ApiKey => { + if self.is_api_login_allowed() { + self.start_api_key_entry(); + } else { + self.disallow_api_login(); + } + } + } + } + fn disallow_api_login(&mut self) { - self.highlighted_mode = AuthMode::ChatGPT; + self.highlighted_mode = SignInOption::ChatGpt; self.error = Some(API_KEY_DISABLED_MESSAGE.to_string()); *self.sign_in_state.write().unwrap() = SignInState::PickMode; self.request_frame.schedule_frame(); @@ -212,7 +269,7 @@ impl AuthModeWidget { ]; let create_mode_item = |idx: usize, - selected_mode: AuthMode, + selected_mode: SignInOption, text: &str, description: &str| -> Vec> { @@ -221,11 +278,11 @@ impl AuthModeWidget { let line1 = if is_selected { Line::from(vec![ - format!("{} {}. ", caret, idx + 1).cyan().dim(), + format!("{caret} {index}. ", index = idx + 1).cyan().dim(), text.to_string().cyan(), ]) } else { - format!(" {}. {text}", idx + 1).into() + format!(" {index}. {text}", index = idx + 1).into() }; let line2 = if is_selected { @@ -242,27 +299,42 @@ impl AuthModeWidget { let chatgpt_description = if !self.is_chatgpt_login_allowed() { "ChatGPT login is disabled" - } else if is_headless_environment() { - "Uses device code login (headless environment detected)" } else { "Usage included with Plus, Pro, Team, and Enterprise plans" }; - lines.extend(create_mode_item( - 0, - AuthMode::ChatGPT, - "Sign in with ChatGPT", - chatgpt_description, - )); - lines.push("".into()); - if self.is_api_login_allowed() { - lines.extend(create_mode_item( - 1, - AuthMode::ApiKey, - "Provide your own API key", - "Pay for what you use", - )); + let device_code_description = "Sign in from another device with a one-time code"; + + for (idx, option) in self.displayed_sign_in_options().into_iter().enumerate() { + match option { + SignInOption::ChatGpt => { + lines.extend(create_mode_item( + idx, + option, + "Sign in with ChatGPT", + chatgpt_description, + )); + } + SignInOption::DeviceCode => { + lines.extend(create_mode_item( + idx, + option, + "Sign in with Device Code", + device_code_description, + )); + } + SignInOption::ApiKey => { + lines.extend(create_mode_item( + idx, + option, + "Provide your own API key", + "Pay for what you use", + )); + } + } lines.push("".into()); - } else { + } + + if !self.is_api_login_allowed() { lines.push( " API key login is disabled by this workspace. Sign in with ChatGPT to continue." .dim() @@ -309,9 +381,9 @@ impl AuthModeWidget { ])); lines.push("".into()); lines.push(Line::from(vec![ - " On a remote or headless machine? Use ".into(), - "codex login --device-auth".cyan(), - " instead".into(), + " On a remote or headless machine? Press Esc and choose ".into(), + "Sign in with Device Code".cyan(), + ".".into(), ])); lines.push("".into()); } @@ -588,13 +660,21 @@ impl AuthModeWidget { self.request_frame.schedule_frame(); } + fn handle_existing_chatgpt_login(&mut self) -> bool { + if matches!(self.login_status, LoginStatus::AuthMode(AuthMode::ChatGPT)) { + *self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess; + self.request_frame.schedule_frame(); + true + } else { + false + } + } + /// Kicks off the ChatGPT auth flow and keeps the UI state consistent with the attempt. fn start_chatgpt_login(&mut self) { // If we're already authenticated with ChatGPT, don't start a new login – // just proceed to the success message flow. - if matches!(self.login_status, LoginStatus::AuthMode(AuthMode::ChatGPT)) { - *self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess; - self.request_frame.schedule_frame(); + if self.handle_existing_chatgpt_login() { return; } @@ -606,11 +686,6 @@ impl AuthModeWidget { self.cli_auth_credentials_store_mode, ); - if is_headless_environment() { - headless_chatgpt_login::start_headless_chatgpt_login(self, opts); - return; - } - match run_login_server(opts) { Ok(child) => { let sign_in_state = self.sign_in_state.clone(); @@ -650,6 +725,21 @@ impl AuthModeWidget { } } } + + fn start_device_code_login(&mut self) { + if self.handle_existing_chatgpt_login() { + return; + } + + self.error = None; + let opts = ServerOptions::new( + self.codex_home.clone(), + CLIENT_ID.to_string(), + self.forced_chatgpt_workspace_id.clone(), + self.cli_auth_credentials_store_mode, + ); + headless_chatgpt_login::start_headless_chatgpt_login(self, opts); + } } impl StepStateProvider for AuthModeWidget { @@ -708,7 +798,7 @@ mod tests { let codex_home_path = codex_home.path().to_path_buf(); let widget = AuthModeWidget { request_frame: FrameRequester::test_dummy(), - highlighted_mode: AuthMode::ChatGPT, + highlighted_mode: SignInOption::ChatGpt, error: None, sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)), codex_home: codex_home_path.clone(), diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index 14999b2229..7e076ae160 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -11,11 +11,11 @@ use ratatui::style::Color; use ratatui::widgets::Clear; use ratatui::widgets::WidgetRef; -use codex_app_server_protocol::AuthMode; use codex_protocol::config_types::ForcedLoginMethod; use crate::LoginStatus; use crate::onboarding::auth::AuthModeWidget; +use crate::onboarding::auth::SignInOption; use crate::onboarding::auth::SignInState; use crate::onboarding::trust_directory::TrustDirectorySelection; use crate::onboarding::trust_directory::TrustDirectoryWidget; @@ -92,8 +92,8 @@ impl OnboardingScreen { ))); if show_login_screen { let highlighted_mode = match forced_login_method { - Some(ForcedLoginMethod::Api) => AuthMode::ApiKey, - _ => AuthMode::ChatGPT, + Some(ForcedLoginMethod::Api) => SignInOption::ApiKey, + _ => SignInOption::ChatGpt, }; steps.push(Step::Auth(AuthModeWidget { request_frame: tui.frame_requester(), diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index ae71a58c6d..5f3f79e320 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -92,6 +92,8 @@ const KEY_SPACE: KeyBinding = key_hint::plain(KeyCode::Char(' ')); const KEY_SHIFT_SPACE: KeyBinding = key_hint::shift(KeyCode::Char(' ')); const KEY_HOME: KeyBinding = key_hint::plain(KeyCode::Home); const KEY_END: KeyBinding = key_hint::plain(KeyCode::End); +const KEY_LEFT: KeyBinding = key_hint::plain(KeyCode::Left); +const KEY_RIGHT: KeyBinding = key_hint::plain(KeyCode::Right); const KEY_CTRL_F: KeyBinding = key_hint::ctrl(KeyCode::Char('f')); const KEY_CTRL_D: KeyBinding = key_hint::ctrl(KeyCode::Char('d')); const KEY_CTRL_B: KeyBinding = key_hint::ctrl(KeyCode::Char('b')); @@ -637,10 +639,13 @@ impl TranscriptOverlay { let line2 = Rect::new(area.x, area.y.saturating_add(1), area.width, 1); render_key_hints(line1, buf, PAGER_KEY_HINTS); - let mut pairs: Vec<(&[KeyBinding], &str)> = - vec![(&[KEY_Q], "to quit"), (&[KEY_ESC], "to edit prev")]; + let mut pairs: Vec<(&[KeyBinding], &str)> = vec![(&[KEY_Q], "to quit")]; if self.highlight_cell.is_some() { + pairs.push((&[KEY_ESC, KEY_LEFT], "to edit prev")); + pairs.push((&[KEY_RIGHT], "to edit next")); pairs.push((&[KEY_ENTER], "to edit message")); + } else { + pairs.push((&[KEY_ESC], "to edit prev")); } render_key_hints(line2, buf, &pairs); } @@ -816,25 +821,37 @@ mod tests { lines: vec![Line::from("hello")], })]); - // Render into a small buffer and assert the backtrack hint is present - let area = Rect::new(0, 0, 40, 10); + // Render into a wide buffer so the footer hints aren't truncated. + let area = Rect::new(0, 0, 120, 10); let mut buf = Buffer::empty(area); overlay.render(area, &mut buf); - // Flatten buffer to a string and check for the hint text - let mut s = String::new(); - for y in area.y..area.bottom() { - for x in area.x..area.right() { - s.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); - } - s.push('\n'); - } + let s = buffer_to_text(&buf, area); assert!( s.contains("edit prev"), "expected 'edit prev' hint in overlay footer, got: {s:?}" ); } + #[test] + fn edit_next_hint_is_visible_when_highlighted() { + let mut overlay = TranscriptOverlay::new(vec![Arc::new(TestCell { + lines: vec![Line::from("hello")], + })]); + overlay.set_highlight_cell(Some(0)); + + // Render into a wide buffer so the footer hints aren't truncated. + let area = Rect::new(0, 0, 120, 10); + let mut buf = Buffer::empty(area); + overlay.render(area, &mut buf); + + let s = buffer_to_text(&buf, area); + assert!( + s.contains("edit next"), + "expected 'edit next' hint in overlay footer, got: {s:?}" + ); + } + #[test] fn transcript_overlay_snapshot_basic() { // Prepare a transcript overlay with a few lines diff --git a/codex-rs/tui/src/public_widgets/composer_input.rs b/codex-rs/tui/src/public_widgets/composer_input.rs index 2a80c087ee..46a7e72bcf 100644 --- a/codex-rs/tui/src/public_widgets/composer_input.rs +++ b/codex-rs/tui/src/public_widgets/composer_input.rs @@ -48,13 +48,14 @@ impl ComposerInput { /// Clear the input text. pub fn clear(&mut self) { - self.inner.set_text_content(String::new()); + self.inner + .set_text_content(String::new(), Vec::new(), Vec::new()); } /// Feed a key event into the composer and return a high-level action. pub fn input(&mut self, key: KeyEvent) -> ComposerAction { let action = match self.inner.handle_key_event(key).0 { - InputResult::Submitted(text) => ComposerAction::Submitted(text), + InputResult::Submitted { text, .. } => ComposerAction::Submitted(text), _ => ComposerAction::None, }; self.drain_app_events(); diff --git a/codex-rs/tui/src/render/renderable.rs b/codex-rs/tui/src/render/renderable.rs index 8581468c0a..eb514b3b0f 100644 --- a/codex-rs/tui/src/render/renderable.rs +++ b/codex-rs/tui/src/render/renderable.rs @@ -275,7 +275,6 @@ impl<'a> FlexRenderable<'a> { }; let child_size = child.desired_height(area.width).min(max_child_extent); child_sizes[i] = child_size; - allocated_size += child_size; allocated_flex_space += child_size; } } diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 5e96c8f2bb..4ce17721cd 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -9,6 +9,7 @@ use codex_core::Cursor; use codex_core::INTERACTIVE_SESSION_SOURCES; use codex_core::RolloutRecorder; use codex_core::ThreadItem; +use codex_core::ThreadSortKey; use codex_core::ThreadsPage; use codex_core::path_utils; use codex_protocol::items::TurnItem; @@ -156,6 +157,7 @@ async fn run_session_picker( &request.codex_home, PAGE_SIZE, request.cursor.as_ref(), + ThreadSortKey::CreatedAt, INTERACTIVE_SESSION_SOURCES, Some(provider_filter.as_slice()), request.default_provider.as_str(), @@ -1360,7 +1362,6 @@ mod tests { "cwd": cwd, "originator": "user", "cli_version": "0.0.0", - "instructions": null, "source": "Cli", "model_provider": "openai", } @@ -1422,6 +1423,7 @@ mod tests { &state.codex_home, PAGE_SIZE, None, + ThreadSortKey::CreatedAt, INTERACTIVE_SESSION_SOURCES, Some(&[String::from("openai")]), "openai", diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 0d274f8eb5..ab63d6c7c8 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -24,6 +24,7 @@ pub enum SlashCommand { Fork, Init, Compact, + Collab, // Undo, Diff, Mention, @@ -48,7 +49,7 @@ impl SlashCommand { SlashCommand::Compact => "summarize conversation to prevent hitting the context limit", SlashCommand::Review => "review my current changes and find issues", SlashCommand::Resume => "resume a saved chat", - SlashCommand::Fork => "fork a saved chat", + SlashCommand::Fork => "fork the current chat", // SlashCommand::Undo => "ask Codex to undo a turn", SlashCommand::Quit | SlashCommand::Exit => "exit Codex", SlashCommand::Diff => "show git diff (including untracked files)", @@ -57,6 +58,7 @@ impl SlashCommand { SlashCommand::Status => "show current session configuration and token usage", SlashCommand::Ps => "list background terminals", SlashCommand::Model => "choose what model and reasoning effort to use", + SlashCommand::Collab => "change collaboration mode (experimental)", SlashCommand::Approvals => "choose what Codex can do without approval", SlashCommand::ElevateSandbox => "set up elevated agent sandbox", SlashCommand::Experimental => "toggle beta features", @@ -99,6 +101,7 @@ impl SlashCommand { | SlashCommand::Exit => true, SlashCommand::Rollout => true, SlashCommand::TestApproval => true, + SlashCommand::Collab => true, } } diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index 7174cf41c9..5e2fe57b8a 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -5,7 +5,8 @@ use crate::history_cell::with_border_with_inner_width; use crate::version::CODEX_CLI_VERSION; use chrono::DateTime; use chrono::Local; -use codex_common::create_config_summary_entries; +use codex_common::summarize_sandbox_policy; +use codex_core::WireApi; use codex_core::config::Config; use codex_core::protocol::NetworkAccess; use codex_core::protocol::SandboxPolicy; @@ -13,6 +14,7 @@ use codex_core::protocol::TokenUsage; use codex_core::protocol::TokenUsageInfo; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; +use codex_protocol::openai_models::ReasoningEffort; use ratatui::prelude::*; use ratatui::style::Stylize; use std::collections::BTreeSet; @@ -63,9 +65,11 @@ struct StatusHistoryCell { approval: String, sandbox: String, agents_summary: String, + collaboration_mode: Option, model_provider: Option, account: Option, session_id: Option, + forked_from: Option, token_usage: StatusTokenUsageData, rate_limits: StatusRateLimitData, } @@ -77,10 +81,13 @@ pub(crate) fn new_status_output( token_info: Option<&TokenUsageInfo>, total_usage: &TokenUsage, session_id: &Option, + forked_from: Option, rate_limits: Option<&RateLimitSnapshotDisplay>, plan_type: Option, now: DateTime, model_name: &str, + collaboration_mode: Option<&str>, + reasoning_effort_override: Option>, ) -> CompositeHistoryCell { let command = PlainHistoryCell::new(vec!["/status".magenta().into()]); let card = StatusHistoryCell::new( @@ -89,10 +96,13 @@ pub(crate) fn new_status_output( token_info, total_usage, session_id, + forked_from, rate_limits, plan_type, now, model_name, + collaboration_mode, + reasoning_effort_override, ); CompositeHistoryCell::new(vec![Box::new(command), Box::new(card)]) @@ -106,12 +116,35 @@ impl StatusHistoryCell { token_info: Option<&TokenUsageInfo>, total_usage: &TokenUsage, session_id: &Option, + forked_from: Option, rate_limits: Option<&RateLimitSnapshotDisplay>, plan_type: Option, now: DateTime, model_name: &str, + collaboration_mode: Option<&str>, + reasoning_effort_override: Option>, ) -> Self { - let config_entries = create_config_summary_entries(config, model_name); + let mut config_entries = vec![ + ("workdir", config.cwd.display().to_string()), + ("model", model_name.to_string()), + ("provider", config.model_provider_id.clone()), + ("approval", config.approval_policy.value().to_string()), + ( + "sandbox", + summarize_sandbox_policy(config.sandbox_policy.get()), + ), + ]; + if config.model_provider.wire_api == WireApi::Responses { + let effort_value = reasoning_effort_override + .unwrap_or(None) + .map(|effort| effort.to_string()) + .unwrap_or_else(|| "none".to_string()); + config_entries.push(("reasoning effort", effort_value)); + config_entries.push(( + "reasoning summaries", + config.model_reasoning_summary.to_string(), + )); + } let (model_name, model_details) = compose_model_display(model_name, &config_entries); let approval = config_entries .iter() @@ -134,6 +167,7 @@ impl StatusHistoryCell { let model_provider = format_model_provider(config); let account = compose_account_display(auth_manager, plan_type); let session_id = session_id.as_ref().map(std::string::ToString::to_string); + let forked_from = forked_from.map(|id| id.to_string()); let default_usage = TokenUsage::default(); let (context_usage, context_window) = match token_info { Some(info) => (&info.last_token_usage, info.model_context_window), @@ -160,9 +194,11 @@ impl StatusHistoryCell { approval, sandbox, agents_summary, + collaboration_mode: collaboration_mode.map(ToString::to_string), model_provider, account, session_id, + forked_from, token_usage, rate_limits, } @@ -351,6 +387,12 @@ impl HistoryCell for StatusHistoryCell { if self.session_id.is_some() { push_label(&mut labels, &mut seen, "Session"); } + if self.session_id.is_some() && self.forked_from.is_some() { + push_label(&mut labels, &mut seen, "Forked from"); + } + if self.collaboration_mode.is_some() { + push_label(&mut labels, &mut seen, "Collaboration mode"); + } push_label(&mut labels, &mut seen, "Token usage"); if self.token_usage.context_window.is_some() { push_label(&mut labels, &mut seen, "Context window"); @@ -400,9 +442,18 @@ impl HistoryCell for StatusHistoryCell { lines.push(formatter.line("Account", vec![Span::from(account_value)])); } + if let Some(collab_mode) = self.collaboration_mode.as_ref() { + lines.push(formatter.line("Collaboration mode", vec![Span::from(collab_mode.clone())])); + } + if let Some(session) = self.session_id.as_ref() { lines.push(formatter.line("Session", vec![Span::from(session.clone())])); } + if self.session_id.is_some() + && let Some(forked_from) = self.forked_from.as_ref() + { + lines.push(formatter.line("Forked from", vec![Span::from(forked_from.clone())])); + } lines.push(Line::from(Vec::>::new())); // Hide token usage only for ChatGPT subscribers diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_forked_from.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_forked_from.snap new file mode 100644 index 0000000000..18772b7d76 --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_forked_from.snap @@ -0,0 +1,24 @@ +--- +source: tui/src/status/tests.rs +expression: sanitized +--- +/status + +╭───────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Approval: on-request │ +│ Sandbox: read-only │ +│ Agents.md: │ +│ Session: 0f0f3c13-6cf9-4aa4-8b80-7d49c2f1be2e │ +│ Forked from: e9f18a88-8081-4e51-9d4e-8af5cde2d8dd │ +│ │ +│ Token usage: 1.2K total (800 input + 400 output) │ +│ Context window: 100% left (1.2K used / 272K) │ +│ Limits: data not available yet │ +╰───────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index bb16f13328..92ed340817 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -14,6 +14,7 @@ use codex_core::protocol::RateLimitWindow; use codex_core::protocol::SandboxPolicy; use codex_core::protocol::TokenUsage; use codex_core::protocol::TokenUsageInfo; +use codex_protocol::ThreadId; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::openai_models::ReasoningEffort; use insta::assert_snapshot; @@ -94,7 +95,6 @@ async fn status_snapshot_includes_reasoning_details() { let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); config.model_provider_id = "openai".to_string(); - config.model_reasoning_effort = Some(ReasoningEffort::High); config.model_reasoning_summary = ReasoningSummary::Detailed; config .sandbox_policy @@ -140,16 +140,73 @@ async fn status_snapshot_includes_reasoning_details() { let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); let token_info = token_info_for(&model_slug, &config, &usage); + let reasoning_effort_override = Some(Some(ReasoningEffort::High)); let composite = new_status_output( &config, &auth_manager, Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, &model_slug, + None, + reasoning_effort_override, + ); + let mut rendered_lines = render_lines(&composite.display_lines(80)); + if cfg!(windows) { + for line in &mut rendered_lines { + *line = line.replace('\\', "/"); + } + } + let sanitized = sanitize_directory(rendered_lines).join("\n"); + assert_snapshot!(sanitized); +} + +#[tokio::test] +async fn status_snapshot_includes_forked_from() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config.model = Some("gpt-5.1-codex-max".to_string()); + config.model_provider_id = "openai".to_string(); + config.cwd = PathBuf::from("/workspace/tests"); + + let auth_manager = test_auth_manager(&config); + let usage = TokenUsage { + input_tokens: 800, + cached_input_tokens: 0, + output_tokens: 400, + reasoning_output_tokens: 0, + total_tokens: 1_200, + }; + + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 8, 9, 10, 11, 12) + .single() + .expect("valid time"); + + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let token_info = token_info_for(&model_slug, &config, &usage); + let session_id = + ThreadId::from_string("0f0f3c13-6cf9-4aa4-8b80-7d49c2f1be2e").expect("session id"); + let forked_from = + ThreadId::from_string("e9f18a88-8081-4e51-9d4e-8af5cde2d8dd").expect("forked id"); + + let composite = new_status_output( + &config, + &auth_manager, + Some(&token_info), + &usage, + &Some(session_id), + Some(forked_from), + None, + None, + captured_at, + &model_slug, + None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -202,10 +259,13 @@ async fn status_snapshot_includes_monthly_limit() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, &model_slug, + None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -246,10 +306,13 @@ async fn status_snapshot_shows_unlimited_credits() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, &model_slug, + None, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -289,10 +352,13 @@ async fn status_snapshot_shows_positive_credits() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, &model_slug, + None, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -332,10 +398,13 @@ async fn status_snapshot_hides_zero_credits() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, &model_slug, + None, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -373,10 +442,13 @@ async fn status_snapshot_hides_when_has_no_credits_flag() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, &model_slug, + None, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -416,8 +488,11 @@ async fn status_card_token_usage_excludes_cached_tokens() { &None, None, None, + None, now, &model_slug, + None, + None, ); let rendered = render_lines(&composite.display_lines(120)); @@ -433,7 +508,6 @@ async fn status_snapshot_truncates_in_narrow_terminal() { let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); config.model_provider_id = "openai".to_string(); - config.model_reasoning_effort = Some(ReasoningEffort::High); config.model_reasoning_summary = ReasoningSummary::Detailed; config.cwd = PathBuf::from("/workspace/tests"); @@ -464,16 +538,20 @@ async fn status_snapshot_truncates_in_narrow_terminal() { let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); let token_info = token_info_for(&model_slug, &config, &usage); + let reasoning_effort_override = Some(Some(ReasoningEffort::High)); let composite = new_status_output( &config, &auth_manager, Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, &model_slug, + None, + reasoning_effort_override, ); let mut rendered_lines = render_lines(&composite.display_lines(70)); if cfg!(windows) { @@ -517,8 +595,11 @@ async fn status_snapshot_shows_missing_limits_message() { &None, None, None, + None, now, &model_slug, + None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -578,10 +659,13 @@ async fn status_snapshot_includes_credits_and_limits() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, &model_slug, + None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -629,10 +713,13 @@ async fn status_snapshot_shows_empty_limits_message() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, &model_slug, + None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -689,10 +776,13 @@ async fn status_snapshot_shows_stale_limits_message() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, now, &model_slug, + None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -753,10 +843,13 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, now, &model_slug, + None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -809,8 +902,11 @@ async fn status_context_window_uses_last_usage() { &None, None, None, + None, now, &model_slug, + None, + None, ); let rendered_lines = render_lines(&composite.display_lines(80)); let context_line = rendered_lines diff --git a/codex-rs/tui/src/style.rs b/codex-rs/tui/src/style.rs index e50d59791e..26a71bd897 100644 --- a/codex-rs/tui/src/style.rs +++ b/codex-rs/tui/src/style.rs @@ -19,10 +19,10 @@ pub fn user_message_style_for(terminal_bg: Option<(u8, u8, u8)>) -> Style { #[allow(clippy::disallowed_methods)] pub fn user_message_bg(terminal_bg: (u8, u8, u8)) -> Color { - let top = if is_light(terminal_bg) { - (0, 0, 0) + let (top, alpha) = if is_light(terminal_bg) { + ((0, 0, 0), 0.04) } else { - (255, 255, 255) + ((255, 255, 255), 0.12) }; - best_color(blend(top, terminal_bg, 0.1)) + best_color(blend(top, terminal_bg, alpha)) } diff --git a/codex-rs/tui/tooltips.txt b/codex-rs/tui/tooltips.txt index 3ba7931ff1..302a2b1ea5 100644 --- a/codex-rs/tui/tooltips.txt +++ b/codex-rs/tui/tooltips.txt @@ -6,7 +6,7 @@ Use /approvals to control when Codex asks for confirmation. Run /review to get a code review of your current changes. Use /skills to list available skills or ask Codex to use one. Use /status to see the current model, approvals, and token usage. -Use /fork to branch a saved chat into a new thread. +Use /fork to branch the current chat into a new thread. Use /init to create an AGENTS.md with project-specific guidance. Use /mcp to list configured MCP tools. You can run any shell command from Codex using `!` (e.g. `!ls`) diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs index 39bb6c097e..920d571451 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -311,7 +311,6 @@ async fn handle_model_migration_prompt_if_needed( from_model: model.to_string(), to_model: target_model.clone(), }); - config.model = Some(target_model.clone()); let mapped_effort = if let Some(reasoning_effort_mapping) = reasoning_effort_mapping && let Some(reasoning_effort) = config.model_reasoning_effort @@ -324,8 +323,8 @@ async fn handle_model_migration_prompt_if_needed( config.model_reasoning_effort }; + config.model = Some(target_model.clone()); config.model_reasoning_effort = mapped_effort; - app_event_tx.send(AppEvent::UpdateModel(target_model.clone())); app_event_tx.send(AppEvent::UpdateReasoningEffort(mapped_effort)); app_event_tx.send(AppEvent::PersistModelSelection { @@ -361,7 +360,6 @@ pub(crate) struct App { pub(crate) auth_manager: Arc, /// Config is stored here so we can recreate ChatWidgets as needed. pub(crate) config: Config, - pub(crate) current_model: String, pub(crate) active_profile: Option, pub(crate) file_search: FileSearchManager, @@ -406,6 +404,11 @@ pub(crate) struct App { // Esc-backtracking state grouped pub(crate) backtrack: crate::app_backtrack::BacktrackState, + /// When set, the next draw re-renders the transcript into terminal scrollback once. + /// + /// This is used after a confirmed conversation rollback to ensure scrollback reflects the + /// trimmed transcript cells. + pub(crate) backtrack_render_pending: bool, pub(crate) feedback: codex_feedback::CodexFeedback, /// Set when the user confirms an update; propagated on exit. pub(crate) pending_update_action: Option, @@ -418,8 +421,30 @@ pub(crate) struct App { skip_world_writable_scan_once: bool, } impl App { + pub fn chatwidget_init_for_forked_or_resumed_thread( + &self, + tui: &mut tui::Tui, + cfg: codex_core::config::Config, + ) -> crate::chatwidget::ChatWidgetInit { + crate::chatwidget::ChatWidgetInit { + config: cfg, + frame_requester: tui.frame_requester(), + app_event_tx: self.app_event_tx.clone(), + // Fork/resume bootstraps here don't carry any prefilled message content. + initial_user_message: None, + 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, + model: Some(self.chat_widget.current_model().to_string()), + } + } + async fn shutdown_current_conversation(&mut self) { if let Some(conversation_id) = self.chat_widget.conversation_id() { + // Clear any in-flight rollback guard when switching conversations. + self.backtrack.pending_rollback = None; self.suppress_shutdown_complete = true; self.chat_widget.submit_op(Op::Shutdown); self.server.remove_thread(&conversation_id).await; @@ -479,14 +504,18 @@ impl App { config: config.clone(), frame_requester: tui.frame_requester(), app_event_tx: app_event_tx.clone(), - initial_prompt: initial_prompt.clone(), - initial_images: initial_images.clone(), + initial_user_message: crate::chatwidget::create_initial_user_message( + initial_prompt.clone(), + initial_images.clone(), + // CLI prompt args are plain strings, so they don't provide element ranges. + Vec::new(), + ), enhanced_keys_supported, auth_manager: auth_manager.clone(), models_manager: thread_manager.get_models_manager(), feedback: feedback.clone(), is_first_run, - model: config.model.clone(), + model: Some(model.clone()), }; ChatWidget::new(init, thread_manager.clone()) } @@ -502,8 +531,12 @@ impl App { config: config.clone(), frame_requester: tui.frame_requester(), app_event_tx: app_event_tx.clone(), - initial_prompt: initial_prompt.clone(), - initial_images: initial_images.clone(), + initial_user_message: crate::chatwidget::create_initial_user_message( + initial_prompt.clone(), + initial_images.clone(), + // CLI prompt args are plain strings, so they don't provide element ranges. + Vec::new(), + ), enhanced_keys_supported, auth_manager: auth_manager.clone(), models_manager: thread_manager.get_models_manager(), @@ -525,8 +558,12 @@ impl App { config: config.clone(), frame_requester: tui.frame_requester(), app_event_tx: app_event_tx.clone(), - initial_prompt: initial_prompt.clone(), - initial_images: initial_images.clone(), + initial_user_message: crate::chatwidget::create_initial_user_message( + initial_prompt.clone(), + initial_images.clone(), + // CLI prompt args are plain strings, so they don't provide element ranges. + Vec::new(), + ), enhanced_keys_supported, auth_manager: auth_manager.clone(), models_manager: thread_manager.get_models_manager(), @@ -566,7 +603,6 @@ impl App { chat_widget, auth_manager: auth_manager.clone(), config, - current_model: model.clone(), active_profile, file_search, enhanced_keys_supported, @@ -587,6 +623,7 @@ impl App { scroll_config, scroll_state: MouseScrollState::default(), backtrack: BacktrackState::default(), + backtrack_render_pending: false, feedback: feedback.clone(), pending_update_action: None, suppress_shutdown_complete: false, @@ -711,6 +748,10 @@ impl App { self.chat_widget.handle_paste(pasted); } TuiEvent::Draw => { + if self.backtrack_render_pending { + self.backtrack_render_pending = false; + self.render_transcript_once(tui); + } self.chat_widget.maybe_post_pending_notification(tui); if self .chat_widget @@ -1430,23 +1471,29 @@ impl App { async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result { match event { AppEvent::NewSession => { + let model = self.chat_widget.current_model().to_string(); + // Propagate selected model into config before spawning thread + self.config.model = Some(model.clone()); let summary = session_summary( self.chat_widget.token_usage(), self.chat_widget.conversation_id(), ); self.shutdown_current_conversation().await; + if let Err(err) = self.server.remove_and_close_all_threads().await { + tracing::warn!(error = %err, "failed to close all threads"); + } let init = crate::chatwidget::ChatWidgetInit { config: self.config.clone(), frame_requester: tui.frame_requester(), app_event_tx: self.app_event_tx.clone(), - initial_prompt: None, - initial_images: Vec::new(), + // New sessions start without prefilled message content. + initial_user_message: None, 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, - model: Some(self.current_model.clone()), + model: Some(model), }; self.chat_widget = ChatWidget::new(init, self.server.clone()); if let Some(summary) = summary { @@ -1484,19 +1531,10 @@ impl App { { Ok(resumed) => { self.shutdown_current_conversation().await; - let init = crate::chatwidget::ChatWidgetInit { - config: self.config.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, - model: Some(self.current_model.clone()), - }; + let init = self.chatwidget_init_for_forked_or_resumed_thread( + tui, + self.config.clone(), + ); self.chat_widget = ChatWidget::new_from_existing( init, resumed.thread, @@ -1531,72 +1569,53 @@ impl App { // Leaving alt-screen may blank the inline viewport; force a redraw either way. tui.frame_requester().schedule_frame(); } - AppEvent::OpenForkPicker => { - match crate::resume_picker::run_fork_picker( - tui, - &self.config.codex_home, - &self.config.model_provider_id, - false, - ) - .await? - { - SessionSelection::Fork(path) => { - let summary = session_summary( - self.chat_widget.token_usage(), - self.chat_widget.conversation_id(), - ); - match self - .server - .fork_thread(usize::MAX, self.config.clone(), path.clone()) - .await - { - Ok(forked) => { - self.shutdown_current_conversation().await; - let init = crate::chatwidget::ChatWidgetInit { - config: self.config.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, - model: Some(self.current_model.clone()), - }; - self.chat_widget = ChatWidget::new_from_existing( - init, - forked.thread, - forked.session_configured, - ); - if let Some(summary) = summary { - let mut lines: Vec> = - vec![summary.usage_line.clone().into()]; - if let Some(command) = summary.resume_command { - let spans = vec![ - "To continue this session, run ".into(), - command.cyan(), - ]; - lines.push(spans.into()); - } - self.chat_widget.add_plain_history_lines(lines); + AppEvent::ForkCurrentSession => { + let summary = session_summary( + self.chat_widget.token_usage(), + self.chat_widget.conversation_id(), + ); + if let Some(path) = self.chat_widget.rollout_path() { + match self + .server + .fork_thread(usize::MAX, self.config.clone(), path.clone()) + .await + { + Ok(forked) => { + self.shutdown_current_conversation().await; + let init = self.chatwidget_init_for_forked_or_resumed_thread( + tui, + self.config.clone(), + ); + self.chat_widget = ChatWidget::new_from_existing( + init, + forked.thread, + forked.session_configured, + ); + if let Some(summary) = summary { + let mut lines: Vec> = + vec![summary.usage_line.clone().into()]; + if let Some(command) = summary.resume_command { + let spans = vec![ + "To continue this session, run ".into(), + command.cyan(), + ]; + lines.push(spans.into()); } - } - Err(err) => { - let path_display = path.display(); - self.chat_widget.add_error_message(format!( - "Failed to fork session from {path_display}: {err}" - )); + self.chat_widget.add_plain_history_lines(lines); } } + Err(err) => { + let path_display = path.display(); + self.chat_widget.add_error_message(format!( + "Failed to fork current session from {path_display}: {err}" + )); + } } - SessionSelection::Exit - | SessionSelection::StartFresh - | SessionSelection::Resume(_) => {} + } else { + self.chat_widget + .add_error_message("Current session is not ready to fork yet.".to_string()); } - // Leaving alt-screen may blank the inline viewport; force a redraw either way. tui.frame_requester().schedule_frame(); } AppEvent::InsertHistoryCell(cell) => { @@ -1646,7 +1665,11 @@ impl App { let errors = errors_for_cwd(&cwd, response); emit_skill_load_warnings(&self.app_event_tx, &errors); } + self.handle_backtrack_event(&event.msg); self.chat_widget.handle_codex_event(event); + if self.backtrack_render_pending { + tui.frame_requester().schedule_frame(); + } } AppEvent::Exit(mode) => match mode { ExitMode::ShutdownFirst => self.chat_widget.submit_op(Op::Shutdown), @@ -1690,7 +1713,11 @@ impl App { } AppEvent::UpdateModel(model) => { self.chat_widget.set_model(&model); - self.current_model = model; + } + AppEvent::UpdateCollaborationMode(mode) => { + let model = mode.model().to_string(); + self.chat_widget.set_collaboration_mode(mode); + self.chat_widget.set_model(&model); } AppEvent::OpenReasoningPopup { model } => { self.chat_widget.open_reasoning_popup(model); @@ -1832,6 +1859,7 @@ impl App { model: None, effort: None, summary: None, + collaboration_mode: None, }, )); self.app_event_tx @@ -2114,8 +2142,10 @@ impl App { } fn on_update_reasoning_effort(&mut self, effort: Option) { - self.chat_widget.set_reasoning_effort(effort); + // TODO(aibrahim): Remove this and don't use config as a state object. + // Instead, explicitly pass the stored collaboration mode's effort into new sessions. self.config.model_reasoning_effort = effort; + self.chat_widget.set_reasoning_effort(effort); } async fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) { @@ -2336,7 +2366,6 @@ mod tests { async fn make_test_app() -> App { let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await; let config = chat_widget.config_ref().clone(); - let current_model = "gpt-5.2-codex".to_string(); let server = Arc::new(ThreadManager::with_models_provider( CodexAuth::from_api_key("Test API Key"), config.model_provider.clone(), @@ -2351,7 +2380,6 @@ mod tests { chat_widget, auth_manager, config, - current_model, active_profile: None, file_search, transcript_cells: Vec::new(), @@ -2374,6 +2402,7 @@ mod tests { scroll_config: ScrollConfig::default(), scroll_state: MouseScrollState::default(), backtrack: BacktrackState::default(), + backtrack_render_pending: false, feedback: codex_feedback::CodexFeedback::new(), pending_update_action: None, suppress_shutdown_complete: false, @@ -2388,7 +2417,6 @@ mod tests { ) { let (chat_widget, app_event_tx, rx, op_rx) = make_chatwidget_manual_with_sender().await; let config = chat_widget.config_ref().clone(); - let current_model = "gpt-5.2-codex".to_string(); let server = Arc::new(ThreadManager::with_models_provider( CodexAuth::from_api_key("Test API Key"), config.model_provider.clone(), @@ -2404,7 +2432,6 @@ mod tests { chat_widget, auth_manager, config, - current_model, active_profile: None, file_search, transcript_cells: Vec::new(), @@ -2427,6 +2454,7 @@ mod tests { scroll_config: ScrollConfig::default(), scroll_state: MouseScrollState::default(), backtrack: BacktrackState::default(), + backtrack_render_pending: false, feedback: codex_feedback::CodexFeedback::new(), pending_update_action: None, suppress_shutdown_complete: false, @@ -2602,20 +2630,19 @@ mod tests { } #[tokio::test] - async fn update_reasoning_effort_updates_config() { + async fn update_reasoning_effort_updates_collaboration_mode() { let mut app = make_test_app().await; - app.config.model_reasoning_effort = Some(ReasoningEffortConfig::Medium); app.chat_widget .set_reasoning_effort(Some(ReasoningEffortConfig::Medium)); app.on_update_reasoning_effort(Some(ReasoningEffortConfig::High)); assert_eq!( - app.config.model_reasoning_effort, + app.chat_widget.current_reasoning_effort(), Some(ReasoningEffortConfig::High) ); assert_eq!( - app.chat_widget.config_ref().model_reasoning_effort, + app.config.model_reasoning_effort, Some(ReasoningEffortConfig::High) ); } @@ -2627,6 +2654,8 @@ mod tests { let user_cell = |text: &str| -> Arc { Arc::new(UserHistoryCell { message: text.to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), }) as Arc }; let agent_cell = |text: &str| -> Arc { @@ -2639,6 +2668,7 @@ mod tests { let make_header = |is_first| { let event = SessionConfiguredEvent { session_id: ThreadId::new(), + forked_from_id: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -2652,7 +2682,7 @@ mod tests { }; Arc::new(new_session_info( app.chat_widget.config_ref(), - app.current_model.as_str(), + app.chat_widget.current_model(), event, is_first, )) as Arc @@ -2680,6 +2710,7 @@ mod tests { id: String::new(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: base_id, + forked_from_id: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -2962,6 +2993,7 @@ mod tests { let conversation_id = ThreadId::new(); let event = SessionConfiguredEvent { session_id: conversation_id, + forked_from_id: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, diff --git a/codex-rs/tui2/src/app_backtrack.rs b/codex-rs/tui2/src/app_backtrack.rs index 1f5ec3e45e..81bf398bf9 100644 --- a/codex-rs/tui2/src/app_backtrack.rs +++ b/codex-rs/tui2/src/app_backtrack.rs @@ -3,6 +3,18 @@ //! This file owns backtrack mode (Esc/Enter navigation in the transcript overlay) and also //! mediates a key rendering boundary for the transcript overlay. //! +//! Overall goal: keep the main chat view and the transcript overlay in sync while allowing +//! users to "rewind" to an earlier user message. We stage a rollback request, wait for core to +//! confirm it, then trim the local transcript to the matching history boundary. This avoids UI +//! state diverging from the agent if a rollback fails or targets a different thread. +//! +//! Backtrack operates as a small state machine: +//! - The first `Esc` in the main view "primes" the feature and captures a base conversation id. +//! - A subsequent `Esc` opens the transcript overlay (`Ctrl+T`) and highlights a user message. +//! - `Enter` requests a rollback from core and records a `pending_rollback` guard. +//! - Only after receiving `EventMsg::ThreadRolledBack` do we trim local transcript state and +//! schedule a one-time scrollback refresh. +//! //! The transcript overlay (`Ctrl+T`) renders committed transcript cells plus a render-only live //! tail derived from the current in-flight `ChatWidget.active_cell`. //! @@ -20,6 +32,9 @@ use crate::history_cell::UserHistoryCell; use crate::pager_overlay::Overlay; use crate::tui; use crate::tui::TuiEvent; +use codex_core::protocol::CodexErrorInfo; +use codex_core::protocol::ErrorEvent; +use codex_core::protocol::EventMsg; use codex_core::protocol::Op; use codex_protocol::ThreadId; use color_eyre::eyre::Result; @@ -32,25 +47,56 @@ 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 rollback. + /// Session id of the base conversation to rollback. + /// + /// If the current conversation changes, backtrack selections become invalid and must be + /// ignored. pub(crate) base_id: Option, - /// Index in the transcript of the last user message. + /// Index of the currently highlighted user message. + /// + /// This is an index into the filtered "user messages since the last session start" view, + /// not an index into `transcript_cells`. `usize::MAX` indicates "no selection". pub(crate) nth_user_message: usize, /// True when the transcript overlay is showing a backtrack preview. pub(crate) overlay_preview_active: bool, + /// Pending rollback request awaiting confirmation from core. + /// + /// This acts as a guardrail: once we request a rollback, we block additional backtrack + /// submissions until core responds with either a success or failure event. + pub(crate) pending_rollback: Option, } +/// A user-visible backtrack choice that can be confirmed into a rollback request. #[derive(Debug, Clone)] pub(crate) struct BacktrackSelection { + /// The selected user message, counted from the most recent session start. + /// + /// This value is used both to compute the rollback depth and to trim the local transcript + /// after core confirms the rollback. pub(crate) nth_user_message: usize, + /// Composer prefill derived from the selected user message. + /// + /// This is applied immediately on selection confirmation; if the rollback fails, the prefill + /// remains as a convenience so the user can retry or edit. pub(crate) prefill: String, } +/// An in-flight rollback requested from core. +/// +/// We keep enough information to apply the corresponding local trim only if the response targets +/// the same active conversation we issued the request for. +#[derive(Debug, Clone)] +pub(crate) struct PendingBacktrackRollback { + pub(crate) selection: BacktrackSelection, + pub(crate) thread_id: Option, +} + impl App { - /// Route overlay events when transcript overlay is active. - /// - If backtrack preview is active: Esc steps selection; Enter confirms. - /// - Otherwise: Esc begins preview; all other events forward to overlay. - /// interactions (Esc to step target, Enter to confirm) and overlay lifecycle. + /// Route overlay events while the transcript overlay is active. + /// + /// If backtrack preview is active, Esc / Left steps selection, Right steps forward, Enter + /// confirms. Otherwise, Esc begins preview mode and all other events are forwarded to the + /// overlay. pub(crate) async fn handle_backtrack_overlay_event( &mut self, tui: &mut tui::Tui, @@ -66,6 +112,22 @@ impl App { self.overlay_step_backtrack(tui, event)?; Ok(true) } + TuiEvent::Key(KeyEvent { + code: KeyCode::Left, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + }) => { + self.overlay_step_backtrack(tui, event)?; + Ok(true) + } + TuiEvent::Key(KeyEvent { + code: KeyCode::Right, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + }) => { + self.overlay_step_backtrack_forward(tui, event)?; + Ok(true) + } TuiEvent::Key(KeyEvent { code: KeyCode::Enter, kind: KeyEventKind::Press, @@ -111,22 +173,42 @@ impl App { } } + /// Stage a backtrack and request thread history from the agent. + /// + /// We send the rollback request immediately, but we only mutate the transcript after core + /// confirms success so the UI cannot get ahead of the actual thread state. + /// + /// The composer prefill is applied immediately as a UX convenience; it does not imply that + /// core has accepted the rollback. pub(crate) fn apply_backtrack_rollback(&mut self, selection: BacktrackSelection) { let user_total = user_count(&self.transcript_cells); if user_total == 0 { return; } + if self.backtrack.pending_rollback.is_some() { + self.chat_widget + .add_error_message("Backtrack rollback already in progress.".to_string()); + 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; } + let prefill = selection.prefill.clone(); + self.backtrack.pending_rollback = Some(PendingBacktrackRollback { + selection, + thread_id: self.chat_widget.conversation_id(), + }); 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); + if !prefill.is_empty() { + // TODO: Rehydrate text_elements/local_image_paths from the selected user cell so + // backtrack preserves image placeholders and attachments. + self.chat_widget + .set_composer_text(prefill, Vec::new(), Vec::new()); } } @@ -246,6 +328,27 @@ impl App { tui.frame_requester().schedule_frame(); } + /// Step selection to the next newer user message and update overlay. + fn step_forward_backtrack_and_highlight(&mut self, tui: &mut tui::Tui) { + let count = user_count(&self.transcript_cells); + if count == 0 { + return; + } + + let last_index = count.saturating_sub(1); + let next_selection = if self.backtrack.nth_user_message == usize::MAX { + last_index + } else { + self.backtrack + .nth_user_message + .saturating_add(1) + .min(last_index) + }; + + 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_internal(&mut self, nth_user_message: usize) { if let Some(cell_idx) = nth_user_position(&self.transcript_cells, nth_user_message) { @@ -311,7 +414,6 @@ impl App { 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(); } } @@ -326,6 +428,20 @@ impl App { Ok(()) } + /// Handle Right in overlay backtrack preview: step selection forward if armed, else forward. + fn overlay_step_backtrack_forward( + &mut self, + tui: &mut tui::Tui, + event: TuiEvent, + ) -> Result<()> { + if self.backtrack.base_id.is_some() { + self.step_forward_backtrack_and_highlight(tui); + } else { + self.overlay_forward_event(tui, event)?; + } + Ok(()) + } + /// Confirm a primed backtrack from the main view (no overlay visible). /// Computes the prefill from the selected user message for rollback. pub(crate) fn confirm_backtrack_from_main(&mut self) -> Option { @@ -349,9 +465,38 @@ impl App { selection: BacktrackSelection, ) { self.apply_backtrack_rollback(selection); - self.render_transcript_once(tui); tui.frame_requester().schedule_frame(); } + + pub(crate) fn handle_backtrack_event(&mut self, event: &EventMsg) { + match event { + EventMsg::ThreadRolledBack(_) => self.finish_pending_backtrack(), + EventMsg::Error(ErrorEvent { + codex_error_info: Some(CodexErrorInfo::ThreadRollbackFailed), + .. + }) => { + // Core rejected the rollback; clear the guard so the user can retry. + self.backtrack.pending_rollback = None; + } + _ => {} + } + } + + /// Finish a pending rollback by applying the local trim and scheduling a scrollback refresh. + /// + /// We ignore events that do not correspond to the currently active conversation to avoid + /// applying stale updates after a session switch. + fn finish_pending_backtrack(&mut self) { + let Some(pending) = self.backtrack.pending_rollback.take() else { + return; + }; + if pending.thread_id != self.chat_widget.conversation_id() { + // Ignore rollbacks targeting a prior thread. + return; + } + self.trim_transcript_for_backtrack(pending.selection.nth_user_message); + self.backtrack_render_pending = true; + } 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) { @@ -370,7 +515,7 @@ impl App { }) } - /// Trim transcript_cells to preserve only content up to the selected user message. + /// Trim `transcript_cells` to preserve only content before 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); } @@ -434,6 +579,8 @@ mod tests { let mut cells: Vec> = vec![ Arc::new(UserHistoryCell { message: "first user".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), }) as Arc, Arc::new(AgentMessageCell::new(vec![Line::from("assistant")], true)) as Arc, @@ -450,6 +597,8 @@ mod tests { as Arc, Arc::new(UserHistoryCell { message: "first".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), }) as Arc, Arc::new(AgentMessageCell::new(vec![Line::from("after")], false)) as Arc, @@ -478,11 +627,15 @@ mod tests { as Arc, Arc::new(UserHistoryCell { message: "first".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), }) as Arc, Arc::new(AgentMessageCell::new(vec![Line::from("between")], false)) as Arc, Arc::new(UserHistoryCell { message: "second".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), }) as Arc, Arc::new(AgentMessageCell::new(vec![Line::from("tail")], false)) as Arc, diff --git a/codex-rs/tui2/src/app_event.rs b/codex-rs/tui2/src/app_event.rs index f24ca1e07c..ff3f0e3875 100644 --- a/codex-rs/tui2/src/app_event.rs +++ b/codex-rs/tui2/src/app_event.rs @@ -21,6 +21,7 @@ use crate::history_cell::HistoryCell; use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; +use codex_protocol::config_types::CollaborationMode; use codex_protocol::openai_models::ReasoningEffort; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -47,8 +48,8 @@ pub(crate) enum AppEvent { /// Open the resume picker inside the running TUI session. OpenResumePicker, - /// Open the fork picker inside the running TUI session. - OpenForkPicker, + /// Fork the current session into a new thread. + ForkCurrentSession, /// Request to exit the application. /// @@ -96,6 +97,9 @@ pub(crate) enum AppEvent { /// Update the current model slug in the running app and widget. UpdateModel(String), + /// Update the current collaboration mode in the running app and widget. + UpdateCollaborationMode(CollaborationMode), + /// Persist the selected model and reasoning effort to the appropriate config. PersistModelSelection { model: String, diff --git a/codex-rs/tui2/src/bottom_pane/chat_composer.rs b/codex-rs/tui2/src/bottom_pane/chat_composer.rs index c9058e0153..34a4795de9 100644 --- a/codex-rs/tui2/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui2/src/bottom_pane/chat_composer.rs @@ -70,7 +70,6 @@ use ratatui::layout::Constraint; use ratatui::layout::Layout; use ratatui::layout::Margin; use ratatui::layout::Rect; -use ratatui::style::Style; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; @@ -81,12 +80,15 @@ use ratatui::widgets::WidgetRef; use super::chat_composer_history::ChatComposerHistory; use super::command_popup::CommandItem; use super::command_popup::CommandPopup; +use super::command_popup::CommandPopupFlags; use super::file_search_popup::FileSearchPopup; use super::footer::FooterMode; use super::footer::FooterProps; use super::footer::esc_hint_mode; use super::footer::footer_height; +use super::footer::inset_footer_hint_area; use super::footer::render_footer; +use super::footer::render_footer_hint_items; use super::footer::reset_mode_after_activity; use super::footer::toggle_shortcut_mode; use super::paste_burst::CharDecision; @@ -109,9 +111,12 @@ use codex_common::fuzzy_match::fuzzy_match; use codex_protocol::custom_prompts::CustomPrompt; use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; use codex_protocol::models::local_image_label_text; +use codex_protocol::user_input::ByteRange; +use codex_protocol::user_input::TextElement; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::LocalImageAttachment; use crate::bottom_pane::textarea::TextArea; use crate::bottom_pane::textarea::TextAreaState; use crate::clipboard_paste::normalize_pasted_path; @@ -123,6 +128,7 @@ use codex_file_search::FileMatch; use std::cell::RefCell; use std::collections::HashMap; use std::collections::HashSet; +use std::collections::VecDeque; use std::path::PathBuf; use std::time::Duration; use std::time::Instant; @@ -141,8 +147,14 @@ const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000; /// Result returned when the user interacts with the text area. #[derive(Debug, PartialEq)] pub enum InputResult { - Submitted(String), - Queued(String), + Submitted { + text: String, + text_elements: Vec, + }, + Queued { + text: String, + text_elements: Vec, + }, Command(SlashCommand), CommandWithArgs(SlashCommand, String), None, @@ -192,6 +204,7 @@ pub(crate) struct ChatComposer { custom_prompts: Vec, footer_mode: FooterMode, footer_hint_override: Option>, + footer_flash: Option, context_window_percent: Option, context_window_used_tokens: Option, transcript_scrolled: bool, @@ -203,6 +216,13 @@ pub(crate) struct ChatComposer { dismissed_skill_popup_token: Option, /// When enabled, `Enter` submits immediately and `Tab` requests queuing behavior. steer_enabled: bool, + collaboration_modes_enabled: bool, +} + +#[derive(Clone, Debug)] +struct FooterFlash { + line: Line<'static>, + expires_at: Instant, } /// Popup state – at most one can be visible at any time. @@ -250,6 +270,7 @@ impl ChatComposer { custom_prompts: Vec::new(), footer_mode: FooterMode::ShortcutSummary, footer_hint_override: None, + footer_flash: None, context_window_percent: None, context_window_used_tokens: None, transcript_scrolled: false, @@ -260,6 +281,7 @@ impl ChatComposer { skills: None, dismissed_skill_popup_token: None, steer_enabled: false, + collaboration_modes_enabled: false, }; // Apply configuration via the setter to keep side-effects centralized. this.set_disable_paste_burst(disable_paste_burst); @@ -280,6 +302,10 @@ impl ChatComposer { self.steer_enabled = enabled; } + pub fn set_collaboration_modes_enabled(&mut self, enabled: bool) { + self.collaboration_modes_enabled = enabled; + } + fn layout_areas(&self, area: Rect) -> [Rect; 3] { let footer_props = self.footer_props(); let footer_hint_height = self @@ -334,7 +360,8 @@ impl ChatComposer { let Some(text) = self.history.on_entry_response(log_id, offset, entry) else { return false; }; - self.set_text_content(text); + // Composer history (↑/↓) stores plain text only; no UI element ranges/attachments to restore here. + self.set_text_content(text, Vec::new(), Vec::new()); true } @@ -357,6 +384,7 @@ impl ChatComposer { /// 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 pasted = pasted.replace("\r\n", "\n").replace('\r', "\n"); let char_count = pasted.chars().count(); if char_count > LARGE_PASTE_CHAR_THRESHOLD { let placeholder = self.next_large_paste_placeholder(char_count); @@ -431,13 +459,46 @@ impl ChatComposer { self.footer_hint_override = items; } + pub(crate) fn show_footer_flash(&mut self, line: Line<'static>, duration: Duration) { + let expires_at = Instant::now() + .checked_add(duration) + .unwrap_or_else(Instant::now); + self.footer_flash = Some(FooterFlash { line, expires_at }); + } + + pub(crate) fn footer_flash_visible(&self) -> bool { + self.footer_flash + .as_ref() + .is_some_and(|flash| Instant::now() < flash.expires_at) + } + /// Replace the entire composer content with `text` and reset cursor. - pub(crate) fn set_text_content(&mut self, text: String) { + /// This clears any pending paste payloads. + pub(crate) fn set_text_content( + &mut self, + text: String, + text_elements: Vec, + local_image_paths: Vec, + ) { // Clear any existing content, placeholders, and attachments first. - self.textarea.set_text(""); + self.textarea.set_text_clearing_elements(""); self.pending_pastes.clear(); self.attached_images.clear(); - self.textarea.set_text(&text); + + self.textarea.set_text_with_elements(&text, &text_elements); + + let image_placeholders: HashSet = text_elements + .iter() + .filter_map(|elem| elem.placeholder(&text).map(str::to_string)) + .collect(); + for (idx, path) in local_image_paths.into_iter().enumerate() { + let placeholder = local_image_label_text(idx + 1); + if image_placeholders.contains(&placeholder) { + self.attached_images + .push(AttachedImage { placeholder, path }); + } + } + self.textarea.set_cursor(0); self.sync_popups(); } @@ -447,7 +508,7 @@ impl ChatComposer { return None; } let previous = self.current_text(); - self.set_text_content(String::new()); + self.set_text_content(String::new(), Vec::new(), Vec::new()); self.history.reset_navigation(); self.history.record_local_submission(&previous); Some(previous) @@ -458,6 +519,28 @@ impl ChatComposer { self.textarea.text().to_string() } + pub(crate) fn text_elements(&self) -> Vec { + self.textarea.text_elements() + } + + #[cfg(test)] + pub(crate) fn local_image_paths(&self) -> Vec { + self.attached_images + .iter() + .map(|img| img.path.clone()) + .collect() + } + + pub(crate) fn local_images(&self) -> Vec { + self.attached_images + .iter() + .map(|img| LocalImageAttachment { + placeholder: img.placeholder.clone(), + path: img.path.clone(), + }) + .collect() + } + /// 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; @@ -469,11 +552,23 @@ impl ChatComposer { .push(AttachedImage { placeholder, path }); } + #[cfg(test)] pub fn take_recent_submission_images(&mut self) -> Vec { let images = std::mem::take(&mut self.attached_images); images.into_iter().map(|img| img.path).collect() } + pub fn take_recent_submission_images_with_placeholders(&mut self) -> Vec { + let images = std::mem::take(&mut self.attached_images); + images + .into_iter() + .map(|img| LocalImageAttachment { + placeholder: img.placeholder, + path: 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: @@ -652,7 +747,7 @@ impl ChatComposer { match sel { CommandItem::Builtin(cmd) => { if cmd == SlashCommand::Skills { - self.textarea.set_text(""); + self.textarea.set_text_clearing_elements(""); return (InputResult::Command(cmd), true); } @@ -660,7 +755,9 @@ impl ChatComposer { .trim_start() .starts_with(&format!("/{}", cmd.command())); if !starts_with_cmd { - self.textarea.set_text(&format!("/{} ", cmd.command())); + // Slash completion replaces the buffer with plain text; drop elements. + self.textarea + .set_text_clearing_elements(&format!("/{} ", cmd.command())); } if !self.textarea.text().is_empty() { cursor_target = Some(self.textarea.text().len()); @@ -675,7 +772,8 @@ impl ChatComposer { ) { PromptSelectionAction::Insert { text, cursor } => { let target = cursor.unwrap_or(text.len()); - self.textarea.set_text(&text); + // Inserted prompt text is plain input; discard any elements. + self.textarea.set_text_clearing_elements(&text); cursor_target = Some(target); } PromptSelectionAction::Submit { .. } => {} @@ -704,14 +802,21 @@ impl ChatComposer { && let Some(expanded) = expand_if_numeric_with_positional_args(prompt, first_line) { - self.textarea.set_text(""); - return (InputResult::Submitted(expanded), true); + self.textarea.set_text_clearing_elements(""); + return ( + InputResult::Submitted { + text: expanded, + // Expanded prompt is plain text; no UI element ranges to preserve. + text_elements: Vec::new(), + }, + true, + ); } if let Some(sel) = popup.selected_item() { match sel { CommandItem::Builtin(cmd) => { - self.textarea.set_text(""); + self.textarea.set_text_clearing_elements(""); return (InputResult::Command(cmd), true); } CommandItem::UserPrompt(idx) => { @@ -722,12 +827,20 @@ impl ChatComposer { PromptSelectionMode::Submit, ) { PromptSelectionAction::Submit { text } => { - self.textarea.set_text(""); - return (InputResult::Submitted(text), true); + self.textarea.set_text_clearing_elements(""); + return ( + InputResult::Submitted { + text, + // Submitting a slash/custom prompt generates plain text, so there are no UI element ranges. + text_elements: Vec::new(), + }, + true, + ); } PromptSelectionAction::Insert { text, cursor } => { let target = cursor.unwrap_or(text.len()); - self.textarea.set_text(&text); + // Inserted prompt text is plain input; discard any elements. + self.textarea.set_text_clearing_elements(&text); self.textarea.set_cursor(target); return (InputResult::None, true); } @@ -1031,6 +1144,106 @@ impl ChatComposer { lower.ends_with(".png") || lower.ends_with(".jpg") || lower.ends_with(".jpeg") } + fn trim_text_elements( + original: &str, + trimmed: &str, + elements: Vec, + ) -> Vec { + if trimmed.is_empty() || elements.is_empty() { + return Vec::new(); + } + let trimmed_start = original.len().saturating_sub(original.trim_start().len()); + let trimmed_end = trimmed_start.saturating_add(trimmed.len()); + + elements + .into_iter() + .filter_map(|elem| { + let start = elem.byte_range.start; + let end = elem.byte_range.end; + if end <= trimmed_start || start >= trimmed_end { + return None; + } + let new_start = start.saturating_sub(trimmed_start); + let new_end = end.saturating_sub(trimmed_start).min(trimmed.len()); + if new_start >= new_end { + return None; + } + let placeholder = trimmed.get(new_start..new_end).map(str::to_string); + Some(TextElement::new( + ByteRange { + start: new_start, + end: new_end, + }, + placeholder, + )) + }) + .collect() + } + + /// Expand large-paste placeholders using element ranges and rebuild other element spans. + fn expand_pending_pastes( + text: &str, + mut elements: Vec, + pending_pastes: &[(String, String)], + ) -> (String, Vec) { + if pending_pastes.is_empty() || elements.is_empty() { + return (text.to_string(), elements); + } + + let mut pending_by_placeholder: HashMap<&str, VecDeque<&str>> = HashMap::new(); + for (placeholder, actual) in pending_pastes { + pending_by_placeholder + .entry(placeholder.as_str()) + .or_default() + .push_back(actual.as_str()); + } + + elements.sort_by_key(|elem| elem.byte_range.start); + + let mut rebuilt = String::with_capacity(text.len()); + let mut rebuilt_elements = Vec::with_capacity(elements.len()); + let mut cursor = 0usize; + + for elem in elements { + let start = elem.byte_range.start.min(text.len()); + let end = elem.byte_range.end.min(text.len()); + if start > end { + continue; + } + if start > cursor { + rebuilt.push_str(&text[cursor..start]); + } + let elem_text = &text[start..end]; + let placeholder = elem.placeholder(text).map(str::to_string); + let replacement = placeholder + .as_deref() + .and_then(|ph| pending_by_placeholder.get_mut(ph)) + .and_then(VecDeque::pop_front); + if let Some(actual) = replacement { + rebuilt.push_str(actual); + } else { + let new_start = rebuilt.len(); + rebuilt.push_str(elem_text); + let new_end = rebuilt.len(); + let placeholder = placeholder.or_else(|| Some(elem_text.to_string())); + rebuilt_elements.push(TextElement::new( + ByteRange { + start: new_start, + end: new_end, + }, + placeholder, + )); + } + cursor = end; + } + + if cursor < text.len() { + rebuilt.push_str(&text[cursor..]); + } + + (rebuilt, rebuilt_elements) + } + fn skills_enabled(&self) -> bool { self.skills.as_ref().is_some_and(|s| !s.is_empty()) } @@ -1205,7 +1418,8 @@ impl ChatComposer { new_text.push(' '); new_text.push_str(&text[end_idx..]); - self.textarea.set_text(&new_text); + // Path replacement is plain text; rebuild without carrying elements. + self.textarea.set_text_clearing_elements(&new_text); let new_cursor = start_idx.saturating_add(inserted.len()).saturating_add(1); self.textarea.set_cursor(new_cursor); } @@ -1240,52 +1454,49 @@ impl ChatComposer { new_text.push(' '); new_text.push_str(&text[end_idx..]); - self.textarea.set_text(&new_text); + // Skill insertion rebuilds plain text, so drop existing elements. + self.textarea.set_text_clearing_elements(&new_text); let new_cursor = start_idx.saturating_add(inserted.len()).saturating_add(1); 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(); - } - + /// On success, clears pending paste payloads because placeholders have been expanded. + fn prepare_submission_text(&mut self) -> Option<(String, Vec)> { let mut text = self.textarea.text().to_string(); let original_input = text.clone(); + let original_text_elements = self.textarea.text_elements(); + let original_local_image_paths = self + .attached_images + .iter() + .map(|img| img.path.clone()) + .collect::>(); + let original_pending_pastes = self.pending_pastes.clone(); + let mut text_elements = original_text_elements.clone(); let input_starts_with_space = original_input.starts_with(' '); - self.textarea.set_text(""); + self.textarea.set_text_clearing_elements(""); - // Replace all pending pastes in the text - for (placeholder, actual) in &self.pending_pastes { - if text.contains(placeholder) { - text = text.replace(placeholder, actual); - } + if !self.pending_pastes.is_empty() { + // Expand placeholders so element byte ranges stay aligned. + let (expanded, expanded_elements) = + Self::expand_pending_pastes(&text, text_elements, &self.pending_pastes); + text = expanded; + text_elements = expanded_elements; } - self.pending_pastes.clear(); + + let expanded_input = text.clone(); // If there is neither text nor attachments, suppress submission entirely. let has_attachments = !self.attached_images.is_empty(); text = text.trim().to_string(); + text_elements = Self::trim_text_elements(&expanded_input, &text, text_elements); 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 is_builtin = + Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled) + .any(|(command_name, _)| command_name == name); let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:"); let is_known_prompt = name .strip_prefix(&prompt_prefix) @@ -1302,7 +1513,12 @@ impl ChatComposer { self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( history_cell::new_info_event(message, None), ))); - self.textarea.set_text(&original_input); + self.set_text_content( + original_input.clone(), + original_text_elements, + original_local_image_paths, + ); + self.pending_pastes.clone_from(&original_pending_pastes); self.textarea.set_cursor(original_input.len()); return None; } @@ -1315,13 +1531,21 @@ impl ChatComposer { self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( history_cell::new_error_event(err.user_message()), ))); - self.textarea.set_text(&original_input); + self.set_text_content( + original_input.clone(), + original_text_elements, + original_local_image_paths, + ); + self.pending_pastes.clone_from(&original_pending_pastes); self.textarea.set_cursor(original_input.len()); return None; } }; if let Some(expanded) = expanded_prompt { text = expanded; + // Expanded prompt (e.g. custom prompt) is plain text; text elements not supported yet. + // TODO: Preserve UI element ranges through prompt expansion in a follow-up PR. + text_elements = Vec::new(); } if text.is_empty() && !has_attachments { return None; @@ -1329,7 +1553,9 @@ impl ChatComposer { if !text.is_empty() { self.history.record_local_submission(&text); } - Some(text) + // Placeholder elements have been expanded into real text, so payloads can be dropped. + self.pending_pastes.clear(); + Some((text, text_elements)) } /// Common logic for handling message submission/queuing. @@ -1378,20 +1604,44 @@ impl ChatComposer { } let original_input = self.textarea.text().to_string(); + let original_text_elements = self.textarea.text_elements(); + let original_local_image_paths = self + .attached_images + .iter() + .map(|img| img.path.clone()) + .collect::>(); + let original_pending_pastes = self.pending_pastes.clone(); if let Some(result) = self.try_dispatch_slash_command_with_args() { return (result, true); } - if let Some(text) = self.prepare_submission_text() { + if let Some((text, text_elements)) = self.prepare_submission_text() { if should_queue { - (InputResult::Queued(text), true) + ( + InputResult::Queued { + text, + text_elements, + }, + true, + ) } else { // Do not clear attached_images here; ChatWidget drains them via take_recent_submission_images(). - (InputResult::Submitted(text), true) + ( + InputResult::Submitted { + text, + text_elements, + }, + true, + ) } } else { - // Restore text if submission was suppressed - self.textarea.set_text(&original_input); + // Restore text if submission was suppressed. + self.set_text_content( + original_input, + original_text_elements, + original_local_image_paths, + ); + self.pending_pastes = original_pending_pastes; (InputResult::None, true) } } @@ -1402,14 +1652,11 @@ impl ChatComposer { 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) + && let Some((_n, cmd)) = + Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled) + .find(|(n, _)| *n == name) { - self.textarea.set_text(""); + self.textarea.set_text_clearing_elements(""); Some(InputResult::Command(cmd)) } else { None @@ -1427,12 +1674,12 @@ impl ChatComposer { 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) + && let Some((_n, cmd)) = + Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled) + .find(|(command_name, _)| *command_name == name) && cmd == SlashCommand::Review { - self.textarea.set_text(""); + self.textarea.set_text_clearing_elements(""); return Some(InputResult::CommandWithArgs(cmd, rest.to_string())); } } @@ -1488,7 +1735,7 @@ impl ChatComposer { _ => unreachable!(), }; if let Some(text) = replace_text { - self.set_text_content(text); + self.set_text_content(text, Vec::new(), Vec::new()); return (InputResult::None, true); } } @@ -1736,6 +1983,8 @@ impl ChatComposer { } fn relabel_attached_images_and_update_placeholders(&mut self) { + // Renumber by insertion order (attachment list order), and update any matching elements + // regardless of where they appear in the text. for idx in 0..self.attached_images.len() { let expected = local_image_label_text(idx + 1); let current = self.attached_images[idx].placeholder.clone(); @@ -1776,6 +2025,7 @@ impl ChatComposer { is_task_running: self.is_task_running, quit_shortcut_key: self.quit_shortcut_key, steer_enabled: self.steer_enabled, + collaboration_modes_enabled: self.collaboration_modes_enabled, context_window_percent: self.context_window_percent, context_window_used_tokens: self.context_window_used_tokens, transcript_scrolled: self.transcript_scrolled, @@ -1803,6 +2053,9 @@ impl ChatComposer { } fn custom_footer_height(&self) -> Option { + if self.footer_flash_visible() { + return Some(1); + } self.footer_hint_override .as_ref() .map(|items| if items.is_empty() { 0 } else { 1 }) @@ -1917,12 +2170,9 @@ impl ChatComposer { return rest_after_name.is_empty(); } - let builtin_match = built_in_slash_commands() - .into_iter() - .filter(|(_, cmd)| { - windows_degraded_sandbox_active() || *cmd != SlashCommand::ElevateSandbox - }) - .any(|(cmd_name, _)| fuzzy_match(cmd_name, name).is_some()); + let builtin_match = + Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled) + .any(|(cmd_name, _)| fuzzy_match(cmd_name, name).is_some()); if builtin_match { return true; @@ -1975,8 +2225,14 @@ impl ChatComposer { _ => { if is_editing_slash_command_name { let skills_enabled = self.skills_enabled(); - let mut command_popup = - CommandPopup::new(self.custom_prompts.clone(), skills_enabled); + let collaboration_modes_enabled = self.collaboration_modes_enabled; + let mut command_popup = CommandPopup::new( + self.custom_prompts.clone(), + CommandPopupFlags { + skills_enabled, + collaboration_modes_enabled, + }, + ); command_popup.on_composer_text_change(first_line.to_string()); self.active_popup = ActivePopup::Command(command_popup); } @@ -1984,6 +2240,16 @@ impl ChatComposer { } } + fn built_in_slash_commands_for_input( + collaboration_modes_enabled: bool, + ) -> impl Iterator { + let allow_elevate_sandbox = windows_degraded_sandbox_active(); + built_in_slash_commands() + .into_iter() + .filter(move |(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox) + .filter(move |(_, cmd)| collaboration_modes_enabled || *cmd != SlashCommand::Collab) + } + pub(crate) fn set_custom_prompts(&mut self, prompts: Vec) { self.custom_prompts = prompts.clone(); if let ActivePopup::Command(popup) = &mut self.active_popup { @@ -2149,24 +2415,12 @@ impl Renderable for ChatComposer { } else { popup_rect }; - if let Some(items) = self.footer_hint_override.as_ref() { - if !items.is_empty() { - let mut spans = Vec::with_capacity(items.len() * 4); - for (idx, (key, label)) in items.iter().enumerate() { - spans.push(" ".into()); - spans.push(Span::styled(key.clone(), Style::default().bold())); - spans.push(format!(" {label}").into()); - if idx + 1 != items.len() { - spans.push(" ".into()); - } - } - let mut custom_rect = hint_rect; - if custom_rect.width > 2 { - custom_rect.x += 2; - custom_rect.width = custom_rect.width.saturating_sub(2); - } - Line::from(spans).render_ref(custom_rect, buf); + if self.footer_flash_visible() { + if let Some(flash) = self.footer_flash.as_ref() { + flash.line.render(inset_footer_hint_area(hint_rect), buf); } + } else if let Some(items) = self.footer_hint_override.as_ref() { + render_footer_hint_items(hint_rect, buf, items); } else { render_footer(hint_rect, buf, footer_props); } @@ -2326,6 +2580,84 @@ mod tests { ); } + #[test] + fn footer_flash_overrides_footer_hint_override() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_footer_hint_override(Some(vec![("K".to_string(), "label".to_string())])); + composer.show_footer_flash(Line::from("FLASH"), Duration::from_secs(10)); + + let area = Rect::new(0, 0, 60, 6); + let mut buf = Buffer::empty(area); + composer.render(area, &mut buf); + + let mut bottom_row = String::new(); + for x in 0..area.width { + bottom_row.push( + buf[(x, area.height - 1)] + .symbol() + .chars() + .next() + .unwrap_or(' '), + ); + } + assert!( + bottom_row.contains("FLASH"), + "expected flash content to render in footer row, saw: {bottom_row:?}", + ); + assert!( + !bottom_row.contains("K label"), + "expected flash to override hint override, saw: {bottom_row:?}", + ); + } + + #[test] + fn footer_flash_expires_and_falls_back_to_hint_override() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_footer_hint_override(Some(vec![("K".to_string(), "label".to_string())])); + composer.show_footer_flash(Line::from("FLASH"), Duration::from_secs(10)); + composer.footer_flash.as_mut().unwrap().expires_at = + Instant::now() - Duration::from_secs(1); + + let area = Rect::new(0, 0, 60, 6); + let mut buf = Buffer::empty(area); + composer.render(area, &mut buf); + + let mut bottom_row = String::new(); + for x in 0..area.width { + bottom_row.push( + buf[(x, area.height - 1)] + .symbol() + .chars() + .next() + .unwrap_or(' '), + ); + } + assert!( + bottom_row.contains("K label"), + "expected hint override to render after flash expired, saw: {bottom_row:?}", + ); + assert!( + !bottom_row.contains("FLASH"), + "expected expired flash to be hidden, saw: {bottom_row:?}", + ); + } + fn snapshot_composer_state(name: &str, enhanced_keys_supported: bool, setup: F) where F: FnOnce(&mut ChatComposer), @@ -2449,7 +2781,7 @@ mod tests { ); composer.set_steer_enabled(true); - composer.set_text_content("draft text".to_string()); + composer.set_text_content("draft text".to_string(), Vec::new(), Vec::new()); assert_eq!(composer.clear_for_ctrl_c(), Some("draft text".to_string())); assert!(composer.is_empty()); @@ -2742,7 +3074,7 @@ mod tests { let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { - InputResult::Submitted(text) => assert_eq!(text, "1あ"), + InputResult::Submitted { text, .. } => assert_eq!(text, "1あ"), _ => panic!("expected Submitted"), } } @@ -2882,6 +3214,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)); @@ -2913,7 +3246,7 @@ mod tests { let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { - InputResult::Submitted(text) => assert_eq!(text, "あ"), + InputResult::Submitted { text, .. } => assert_eq!(text, "あ"), _ => panic!("expected Submitted"), } } @@ -2979,7 +3312,7 @@ mod tests { false, ); - composer.textarea.set_text("/diff"); + composer.textarea.set_text_clearing_elements("/diff"); composer.textarea.set_cursor("/diff".len()); composer .paste_burst @@ -3117,7 +3450,7 @@ mod tests { let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { - InputResult::Submitted(text) => assert_eq!(text, "hello"), + InputResult::Submitted { text, .. } => assert_eq!(text, "hello"), _ => panic!("expected Submitted"), } } @@ -3181,7 +3514,7 @@ mod tests { let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { - InputResult::Submitted(text) => assert_eq!(text, large), + InputResult::Submitted { text, .. } => assert_eq!(text, large), _ => panic!("expected Submitted"), } assert!(composer.pending_pastes.is_empty()); @@ -3443,10 +3776,10 @@ mod tests { InputResult::CommandWithArgs(_, _) => { panic!("expected command dispatch without args for '/init'") } - InputResult::Submitted(text) => { + InputResult::Submitted { text, .. } => { panic!("expected command dispatch, but composer submitted literal text: {text}") } - InputResult::Queued(_) => { + InputResult::Queued { .. } => { panic!("expected command dispatch, but composer queued literal text") } InputResult::None => panic!("expected Command result for '/init'"), @@ -3522,10 +3855,10 @@ mod tests { InputResult::CommandWithArgs(_, _) => { panic!("expected command dispatch without args for '/diff'") } - InputResult::Submitted(text) => { + InputResult::Submitted { text, .. } => { panic!("expected command dispatch after Tab completion, got literal submit: {text}") } - InputResult::Queued(_) => { + InputResult::Queued { .. } => { panic!("expected command dispatch after Tab completion, got literal queue") } InputResult::None => panic!("expected Command result for '/diff'"), @@ -3561,10 +3894,10 @@ mod tests { InputResult::CommandWithArgs(_, _) => { panic!("expected command dispatch without args for '/mention'") } - InputResult::Submitted(text) => { + InputResult::Submitted { text, .. } => { panic!("expected command dispatch, but composer submitted literal text: {text}") } - InputResult::Queued(_) => { + InputResult::Queued { .. } => { panic!("expected command dispatch, but composer queued literal text") } InputResult::None => panic!("expected Command result for '/mention'"), @@ -3649,7 +3982,7 @@ mod tests { // Submit and verify final expansion let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - if let InputResult::Submitted(text) = result { + if let InputResult::Submitted { text, .. } = result { assert_eq!(text, format!("{} and {}", test_cases[0].0, test_cases[2].0)); } else { panic!("expected Submitted"); @@ -3846,7 +4179,7 @@ mod tests { composer.textarea.text().contains(&placeholder), composer.pending_pastes.len(), ); - composer.textarea.set_text(""); + composer.textarea.set_text_clearing_elements(""); result }) .collect(); @@ -3862,7 +4195,7 @@ mod tests { // --- Image attachment tests --- #[test] - fn attach_image_and_submit_includes_image_paths() { + fn attach_image_and_submit_includes_local_image_paths() { let (tx, _rx) = unbounded_channel::(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new( @@ -3879,13 +4212,228 @@ mod tests { let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { - InputResult::Submitted(text) => assert_eq!(text, "[Image #1] hi"), + InputResult::Submitted { + text, + text_elements, + } => { + assert_eq!(text, "[Image #1] hi"); + assert_eq!(text_elements.len(), 1); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); + assert_eq!( + text_elements[0].byte_range, + ByteRange { + start: 0, + end: "[Image #1]".len() + } + ); + } _ => panic!("expected Submitted"), } let imgs = composer.take_recent_submission_images(); assert_eq!(vec![path], imgs); } + #[test] + fn set_text_content_reattaches_images_without_placeholder_metadata() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + let placeholder = local_image_label_text(1); + let text = format!("{placeholder} restored"); + let text_elements = vec![TextElement::new((0..placeholder.len()).into(), None)]; + let path = PathBuf::from("/tmp/image1.png"); + + composer.set_text_content(text, text_elements, vec![path.clone()]); + + assert_eq!(composer.local_image_paths(), vec![path]); + } + + #[test] + fn large_paste_preserves_image_text_elements_on_submit() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(true); + + let large_content = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5); + composer.handle_paste(large_content.clone()); + composer.handle_paste(" ".into()); + let path = PathBuf::from("/tmp/image_with_paste.png"); + composer.attach_image(path.clone()); + + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted { + text, + text_elements, + } => { + let expected = format!("{large_content} [Image #1]"); + assert_eq!(text, expected); + assert_eq!(text_elements.len(), 1); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); + assert_eq!( + text_elements[0].byte_range, + ByteRange { + start: large_content.len() + 1, + end: large_content.len() + 1 + "[Image #1]".len(), + } + ); + } + _ => panic!("expected Submitted"), + } + let imgs = composer.take_recent_submission_images(); + assert_eq!(vec![path], imgs); + } + + #[test] + fn large_paste_with_leading_whitespace_trims_and_shifts_elements() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(true); + + let large_content = format!(" {}", "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5)); + composer.handle_paste(large_content.clone()); + composer.handle_paste(" ".into()); + let path = PathBuf::from("/tmp/image_with_trim.png"); + composer.attach_image(path.clone()); + + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted { + text, + text_elements, + } => { + let trimmed = large_content.trim().to_string(); + assert_eq!(text, format!("{trimmed} [Image #1]")); + assert_eq!(text_elements.len(), 1); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); + assert_eq!( + text_elements[0].byte_range, + ByteRange { + start: trimmed.len() + 1, + end: trimmed.len() + 1 + "[Image #1]".len(), + } + ); + } + _ => panic!("expected Submitted"), + } + let imgs = composer.take_recent_submission_images(); + assert_eq!(vec![path], imgs); + } + + #[test] + fn pasted_crlf_normalizes_newlines_for_elements() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(true); + + let pasted = "line1\r\nline2\r\n".to_string(); + composer.handle_paste(pasted); + composer.handle_paste(" ".into()); + let path = PathBuf::from("/tmp/image_crlf.png"); + composer.attach_image(path.clone()); + + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted { + text, + text_elements, + } => { + assert_eq!(text, "line1\nline2\n [Image #1]"); + assert!(!text.contains('\r')); + assert_eq!(text_elements.len(), 1); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); + assert_eq!( + text_elements[0].byte_range, + ByteRange { + start: "line1\nline2\n ".len(), + end: "line1\nline2\n [Image #1]".len(), + } + ); + } + _ => panic!("expected Submitted"), + } + let imgs = composer.take_recent_submission_images(); + assert_eq!(vec![path], imgs); + } + + #[test] + fn suppressed_submission_restores_pending_paste_payload() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(true); + + composer.textarea.set_text_clearing_elements("/unknown "); + composer.textarea.set_cursor("/unknown ".len()); + let large_content = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5); + composer.handle_paste(large_content.clone()); + let placeholder = composer + .pending_pastes + .first() + .expect("expected pending paste") + .0 + .clone(); + + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!(result, InputResult::None)); + assert_eq!(composer.pending_pastes.len(), 1); + assert_eq!(composer.textarea.text(), format!("/unknown {placeholder}")); + + composer.textarea.set_cursor(0); + composer.textarea.insert_str(" "); + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted { + text, + text_elements, + } => { + assert_eq!(text, format!("/unknown {large_content}")); + assert!(text_elements.is_empty()); + } + _ => panic!("expected Submitted"), + } + assert!(composer.pending_pastes.is_empty()); + } + #[test] fn attach_image_without_text_submits_empty_text_and_images() { let (tx, _rx) = unbounded_channel::(); @@ -3903,7 +4451,21 @@ mod tests { let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { - InputResult::Submitted(text) => assert_eq!(text, "[Image #1]"), + InputResult::Submitted { + text, + text_elements, + } => { + assert_eq!(text, "[Image #1]"); + assert_eq!(text_elements.len(), 1); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); + assert_eq!( + text_elements[0].byte_range, + ByteRange { + start: 0, + end: "[Image #1]".len() + } + ); + } _ => panic!("expected Submitted"), } let imgs = composer.take_recent_submission_images(); @@ -4030,6 +4592,69 @@ mod tests { ); } + #[test] + fn deleting_reordered_image_one_renumbers_text_in_place() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + let path1 = PathBuf::from("/tmp/image_first.png"); + let path2 = PathBuf::from("/tmp/image_second.png"); + let placeholder1 = local_image_label_text(1); + let placeholder2 = local_image_label_text(2); + + // Placeholders can be reordered in the text buffer; deleting image #1 should renumber + // image #2 wherever it appears, not just after the cursor. + let text = format!("Test {placeholder2} test {placeholder1}"); + let start2 = text.find(&placeholder2).expect("placeholder2 present"); + let start1 = text.find(&placeholder1).expect("placeholder1 present"); + let text_elements = vec![ + TextElement::new( + ByteRange { + start: start2, + end: start2 + placeholder2.len(), + }, + Some(placeholder2), + ), + TextElement::new( + ByteRange { + start: start1, + end: start1 + placeholder1.len(), + }, + Some(placeholder1.clone()), + ), + ]; + composer.set_text_content(text, text_elements, vec![path1, path2.clone()]); + + let end1 = start1 + placeholder1.len(); + composer.textarea.set_cursor(end1); + + composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + + assert_eq!( + composer.textarea.text(), + format!("Test {placeholder1} test ") + ); + assert_eq!( + vec![AttachedImage { + path: path2, + placeholder: placeholder1 + }], + composer.attached_images, + "attachment renumbered after deletion" + ); + } + #[test] fn deleting_first_text_element_renumbers_following_text_element() { use crossterm::event::KeyCode; @@ -4127,7 +4752,10 @@ mod tests { let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!(InputResult::Submitted(prompt_text.to_string()), result); + assert!(matches!( + result, + InputResult::Submitted { text, .. } if text == prompt_text + )); assert!(composer.textarea.is_empty()); } @@ -4154,15 +4782,16 @@ mod tests { composer .textarea - .set_text("/prompts:my-prompt USER=Alice BRANCH=main"); + .set_text_clearing_elements("/prompts:my-prompt USER=Alice BRANCH=main"); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!( - InputResult::Submitted("Review Alice changes on main".to_string()), - result - ); + assert!(matches!( + result, + InputResult::Submitted { text, .. } + if text == "Review Alice changes on main" + )); assert!(composer.textarea.is_empty()); } @@ -4189,15 +4818,16 @@ mod tests { composer .textarea - .set_text("/prompts:my-prompt USER=\"Alice Smith\" BRANCH=dev-main"); + .set_text_clearing_elements("/prompts:my-prompt USER=\"Alice Smith\" BRANCH=dev-main"); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!( - InputResult::Submitted("Pair Alice Smith with dev-main".to_string()), - result - ); + assert!(matches!( + result, + InputResult::Submitted { text, .. } + if text == "Pair Alice Smith with dev-main" + )); assert!(composer.textarea.is_empty()); } @@ -4231,7 +4861,7 @@ mod tests { // Type the slash command let command_text = "/prompts:code-review "; - composer.textarea.set_text(command_text); + composer.textarea.set_text_clearing_elements(command_text); composer.textarea.set_cursor(command_text.len()); // Paste large content (>3000 chars) to trigger placeholder @@ -4254,7 +4884,7 @@ mod tests { // Verify the custom prompt was expanded with the large content as positional arg match result { - InputResult::Submitted(text) => { + InputResult::Submitted { text, .. } => { // The prompt should be expanded, with the large content replacing $1 assert_eq!( text, @@ -4287,12 +4917,12 @@ mod tests { composer .textarea - .set_text("/Users/example/project/src/main.rs"); + .set_text_clearing_elements("/Users/example/project/src/main.rs"); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - if let InputResult::Submitted(text) = result { + if let InputResult::Submitted { text, .. } = result { assert_eq!(text, "/Users/example/project/src/main.rs"); } else { panic!("expected Submitted"); @@ -4322,12 +4952,14 @@ mod tests { ); composer.set_steer_enabled(true); - composer.textarea.set_text(" /this-looks-like-a-command"); + composer + .textarea + .set_text_clearing_elements(" /this-looks-like-a-command"); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - if let InputResult::Submitted(text) = result { + if let InputResult::Submitted { text, .. } = result { assert_eq!(text, "/this-looks-like-a-command"); } else { panic!("expected Submitted"); @@ -4362,7 +4994,7 @@ mod tests { composer .textarea - .set_text("/prompts:my-prompt USER=Alice stray"); + .set_text_clearing_elements("/prompts:my-prompt USER=Alice stray"); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); @@ -4411,7 +5043,9 @@ mod tests { }]); // Provide only one of the required args - composer.textarea.set_text("/prompts:my-prompt USER=Alice"); + composer + .textarea + .set_text_clearing_elements("/prompts:my-prompt USER=Alice"); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); @@ -4476,7 +5110,10 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); let expected = "Header: foo\nArgs: foo bar\nNinth: \n".to_string(); - assert_eq!(InputResult::Submitted(expected), result); + assert!(matches!( + result, + InputResult::Submitted { text, .. } if text == expected + )); } #[test] @@ -4503,11 +5140,16 @@ mod tests { }]); // Type positional args; should submit with numeric expansion, no errors. - composer.textarea.set_text("/prompts:elegant hi"); + composer + .textarea + .set_text_clearing_elements("/prompts:elegant hi"); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!(InputResult::Submitted("Echo: hi".to_string()), result); + assert!(matches!( + result, + InputResult::Submitted { text, .. } if text == "Echo: hi" + )); assert!(composer.textarea.is_empty()); } @@ -4579,10 +5221,11 @@ mod tests { let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!( - InputResult::Submitted("Cost: $$ and first: x".to_string()), - result - ); + assert!(matches!( + result, + InputResult::Submitted { text, .. } + if text == "Cost: $$ and first: x" + )); } #[test] @@ -4619,7 +5262,10 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); let expected = "First: one two\nSecond: one two".to_string(); - assert_eq!(InputResult::Submitted(expected), result); + assert!(matches!( + result, + InputResult::Submitted { text, .. } if text == expected + )); } /// Behavior: fast "paste-like" ASCII input should buffer and then flush as a single paste. If @@ -4744,7 +5390,7 @@ mod tests { ); // Simulate history-like content: "/ test" - composer.set_text_content("/ test".to_string()); + composer.set_text_content("/ test".to_string(), Vec::new(), Vec::new()); // After set_text_content -> sync_popups is called; popup should NOT be Command. assert!( @@ -4774,21 +5420,21 @@ mod tests { ); // Case 1: bare "/" - composer.set_text_content("/".to_string()); + composer.set_text_content("/".to_string(), Vec::new(), Vec::new()); assert!( matches!(composer.active_popup, ActivePopup::Command(_)), "bare '/' should activate slash popup" ); // Case 2: valid prefix "/re" (matches /review, /resume, etc.) - composer.set_text_content("/re".to_string()); + composer.set_text_content("/re".to_string(), Vec::new(), Vec::new()); assert!( matches!(composer.active_popup, ActivePopup::Command(_)), "'/re' should activate slash popup via prefix match" ); // Case 3: fuzzy match "/ac" (subsequence of /compact and /feedback) - composer.set_text_content("/ac".to_string()); + composer.set_text_content("/ac".to_string(), Vec::new(), Vec::new()); assert!( matches!(composer.active_popup, ActivePopup::Command(_)), "'/ac' should activate slash popup via fuzzy match" @@ -4797,7 +5443,7 @@ mod tests { // Case 4: invalid prefix "/zzz" – still allowed to open popup if it // matches no built-in command; our current logic will not open popup. // Verify that explicitly. - composer.set_text_content("/zzz".to_string()); + composer.set_text_content("/zzz".to_string(), Vec::new(), Vec::new()); assert!( matches!(composer.active_popup, ActivePopup::None), "'/zzz' should not activate slash popup because it is not a prefix of any built-in command" @@ -4820,7 +5466,7 @@ mod tests { false, ); - composer.set_text_content("hello".to_string()); + composer.set_text_content("hello".to_string(), Vec::new(), Vec::new()); composer.set_input_enabled(false, Some("Input disabled for test.".to_string())); let (result, needs_redraw) = diff --git a/codex-rs/tui2/src/bottom_pane/command_popup.rs b/codex-rs/tui2/src/bottom_pane/command_popup.rs index 2fee23f1b6..000ce2e4ea 100644 --- a/codex-rs/tui2/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui2/src/bottom_pane/command_popup.rs @@ -37,13 +37,20 @@ pub(crate) struct CommandPopup { state: ScrollState, } +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct CommandPopupFlags { + pub(crate) skills_enabled: bool, + pub(crate) collaboration_modes_enabled: bool, +} + impl CommandPopup { - pub(crate) fn new(mut prompts: Vec, skills_enabled: bool) -> Self { + pub(crate) fn new(mut prompts: Vec, flags: CommandPopupFlags) -> Self { let allow_elevate_sandbox = windows_degraded_sandbox_active(); let builtins: Vec<(&'static str, SlashCommand)> = built_in_slash_commands() .into_iter() - .filter(|(_, cmd)| skills_enabled || *cmd != SlashCommand::Skills) + .filter(|(_, cmd)| flags.skills_enabled || *cmd != SlashCommand::Skills) .filter(|(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox) + .filter(|(_, cmd)| flags.collaboration_modes_enabled || *cmd != SlashCommand::Collab) .collect(); // Exclude prompts that collide with builtin command names and sort by name. let exclude: HashSet = builtins.iter().map(|(n, _)| (*n).to_string()).collect(); @@ -113,8 +120,8 @@ impl CommandPopup { } /// Compute fuzzy-filtered matches over built-in commands and user prompts, - /// paired with optional highlight indices and score. Sorted by ascending - /// score, then by name for stability. + /// paired with optional highlight indices and score. Preserves the original + /// presentation order for built-ins and prompts. fn filtered(&self) -> Vec<(CommandItem, Option>, i32)> { let filter = self.command_filter.trim(); let mut out: Vec<(CommandItem, Option>, i32)> = Vec::new(); @@ -144,20 +151,6 @@ impl CommandPopup { out.push((CommandItem::UserPrompt(idx), Some(indices), score)); } } - // When filtering, sort by ascending score and then by name for stability. - out.sort_by(|a, b| { - a.2.cmp(&b.2).then_with(|| { - let an = match a.0 { - CommandItem::Builtin(c) => c.command(), - CommandItem::UserPrompt(i) => &self.prompts[i].name, - }; - let bn = match b.0 { - CommandItem::Builtin(c) => c.command(), - CommandItem::UserPrompt(i) => &self.prompts[i].name, - }; - an.cmp(bn) - }) - }); out } @@ -244,7 +237,7 @@ mod tests { #[test] fn filter_includes_init_when_typing_prefix() { - let mut popup = CommandPopup::new(Vec::new(), false); + let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); // Simulate the composer line starting with '/in' so the popup filters // matching commands by prefix. popup.on_composer_text_change("/in".to_string()); @@ -264,7 +257,7 @@ mod tests { #[test] fn selecting_init_by_exact_match() { - let mut popup = CommandPopup::new(Vec::new(), false); + let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); popup.on_composer_text_change("/init".to_string()); // When an exact match exists, the selected command should be that @@ -279,7 +272,7 @@ mod tests { #[test] fn model_is_first_suggestion_for_mo() { - let mut popup = CommandPopup::new(Vec::new(), false); + let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); popup.on_composer_text_change("/mo".to_string()); let matches = popup.filtered_items(); match matches.first() { @@ -291,6 +284,22 @@ mod tests { } } + #[test] + fn filtered_commands_keep_presentation_order() { + let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); + popup.on_composer_text_change("/m".to_string()); + + let cmds: Vec<&str> = popup + .filtered_items() + .into_iter() + .filter_map(|item| match item { + CommandItem::Builtin(cmd) => Some(cmd.command()), + CommandItem::UserPrompt(_) => None, + }) + .collect(); + assert_eq!(cmds, vec!["model", "resume", "compact", "mention", "mcp"]); + } + #[test] fn prompt_discovery_lists_custom_prompts() { let prompts = vec![ @@ -309,7 +318,7 @@ mod tests { argument_hint: None, }, ]; - let popup = CommandPopup::new(prompts, false); + let popup = CommandPopup::new(prompts, CommandPopupFlags::default()); let items = popup.filtered_items(); let mut prompt_names: Vec = items .into_iter() @@ -333,7 +342,7 @@ mod tests { description: None, argument_hint: None, }], - false, + CommandPopupFlags::default(), ); let items = popup.filtered_items(); let has_collision_prompt = items.into_iter().any(|it| match it { @@ -356,7 +365,7 @@ mod tests { description: Some("Create feature branch, commit and open draft PR.".to_string()), argument_hint: None, }], - false, + CommandPopupFlags::default(), ); let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None, 0)]); let description = rows.first().and_then(|row| row.description.as_deref()); @@ -376,7 +385,7 @@ mod tests { description: None, argument_hint: None, }], - false, + CommandPopupFlags::default(), ); let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None, 0)]); let description = rows.first().and_then(|row| row.description.as_deref()); @@ -385,7 +394,7 @@ mod tests { #[test] fn fuzzy_filter_matches_subsequence_for_ac() { - let mut popup = CommandPopup::new(Vec::new(), false); + let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); popup.on_composer_text_change("/ac".to_string()); let cmds: Vec<&str> = popup @@ -401,4 +410,40 @@ mod tests { "expected fuzzy search for '/ac' to include compact and feedback, got {cmds:?}" ); } + + #[test] + fn collab_command_hidden_when_collaboration_modes_disabled() { + let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); + popup.on_composer_text_change("/coll".to_string()); + + let cmds: Vec<&str> = popup + .filtered_items() + .into_iter() + .filter_map(|item| match item { + CommandItem::Builtin(cmd) => Some(cmd.command()), + CommandItem::UserPrompt(_) => None, + }) + .collect(); + assert!( + !cmds.contains(&"collab"), + "expected '/collab' to be hidden when collaboration modes are disabled, got {cmds:?}" + ); + } + + #[test] + fn collab_command_visible_when_collaboration_modes_enabled() { + let mut popup = CommandPopup::new( + Vec::new(), + CommandPopupFlags { + skills_enabled: false, + collaboration_modes_enabled: true, + }, + ); + popup.on_composer_text_change("/collab".to_string()); + + match popup.selected_item() { + Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "collab"), + other => panic!("expected collab to be selected for exact match, got {other:?}"), + } + } } diff --git a/codex-rs/tui2/src/bottom_pane/footer.rs b/codex-rs/tui2/src/bottom_pane/footer.rs index bdfb5c8dfc..fe5a13dc48 100644 --- a/codex-rs/tui2/src/bottom_pane/footer.rs +++ b/codex-rs/tui2/src/bottom_pane/footer.rs @@ -38,6 +38,7 @@ pub(crate) struct FooterProps { pub(crate) use_shift_enter_hint: bool, pub(crate) is_task_running: bool, pub(crate) steer_enabled: bool, + pub(crate) collaboration_modes_enabled: bool, /// Which key the user must press again to quit. /// /// This is rendered when `mode` is `FooterMode::QuitShortcutReminder`. @@ -109,6 +110,31 @@ pub(crate) fn render_footer(area: Rect, buf: &mut Buffer, props: FooterProps) { .render(area, buf); } +pub(crate) fn inset_footer_hint_area(mut area: Rect) -> Rect { + if area.width > 2 { + area.x += 2; + area.width = area.width.saturating_sub(2); + } + area +} + +pub(crate) fn render_footer_hint_items(area: Rect, buf: &mut Buffer, items: &[(String, String)]) { + if items.is_empty() { + return; + } + + let mut spans = Vec::with_capacity(items.len() * 4); + for (idx, (key, label)) in items.iter().enumerate() { + spans.push(" ".into()); + spans.push(key.clone().bold()); + spans.push(format!(" {label}").into()); + if idx + 1 != items.len() { + spans.push(" ".into()); + } + } + Line::from(spans).render(inset_footer_hint_area(area), buf); +} + fn footer_lines(props: FooterProps) -> Vec> { fn apply_copy_feedback(lines: &mut [Line<'static>], feedback: Option) { let Some(line) = lines.first_mut() else { @@ -176,6 +202,7 @@ fn footer_lines(props: FooterProps) -> Vec> { use_shift_enter_hint: props.use_shift_enter_hint, esc_backtrack_hint: props.esc_backtrack_hint, is_wsl, + collaboration_modes_enabled: props.collaboration_modes_enabled, }; shortcut_overlay_lines(state) } @@ -202,6 +229,7 @@ struct ShortcutsState { use_shift_enter_hint: bool, esc_backtrack_hint: bool, is_wsl: bool, + collaboration_modes_enabled: bool, } fn quit_shortcut_reminder_line(key: KeyBinding) -> Line<'static> { @@ -233,6 +261,7 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { let mut edit_previous = Line::from(""); let mut quit = Line::from(""); let mut show_transcript = Line::from(""); + let mut change_mode = Line::from(""); for descriptor in SHORTCUTS { if let Some(text) = descriptor.overlay_entry(state) { @@ -246,11 +275,12 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { ShortcutId::EditPrevious => edit_previous = text, ShortcutId::Quit => quit = text, ShortcutId::ShowTranscript => show_transcript = text, + ShortcutId::ChangeMode => change_mode = text, } } } - let ordered = vec![ + let mut ordered = vec![ commands, shell_commands, newline, @@ -259,9 +289,12 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { paste_image, edit_previous, quit, - Line::from(""), - show_transcript, ]; + if change_mode.width() > 0 { + ordered.push(change_mode); + } + ordered.push(Line::from("")); + ordered.push(show_transcript); build_columns(ordered) } @@ -338,6 +371,7 @@ enum ShortcutId { EditPrevious, Quit, ShowTranscript, + ChangeMode, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -358,6 +392,7 @@ enum DisplayCondition { WhenShiftEnterHint, WhenNotShiftEnterHint, WhenUnderWSL, + WhenCollaborationModesEnabled, } impl DisplayCondition { @@ -367,6 +402,7 @@ impl DisplayCondition { DisplayCondition::WhenShiftEnterHint => state.use_shift_enter_hint, DisplayCondition::WhenNotShiftEnterHint => !state.use_shift_enter_hint, DisplayCondition::WhenUnderWSL => state.is_wsl, + DisplayCondition::WhenCollaborationModesEnabled => state.collaboration_modes_enabled, } } } @@ -500,6 +536,15 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ prefix: "", label: " to view transcript", }, + ShortcutDescriptor { + id: ShortcutId::ChangeMode, + bindings: &[ShortcutBinding { + key: key_hint::shift(KeyCode::Tab), + condition: DisplayCondition::WhenCollaborationModesEnabled, + }], + prefix: "", + label: " to change mode", + }, ]; #[cfg(test)] @@ -531,6 +576,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, @@ -550,6 +596,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, @@ -569,6 +616,27 @@ mod tests { use_shift_enter_hint: true, is_task_running: false, steer_enabled: false, + collaboration_modes_enabled: false, + quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), + 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_shortcuts_collaboration_modes_enabled", + FooterProps { + mode: FooterMode::ShortcutOverlay, + esc_backtrack_hint: true, + use_shift_enter_hint: true, + is_task_running: false, + steer_enabled: false, + collaboration_modes_enabled: true, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, @@ -588,6 +656,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, @@ -607,6 +676,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: true, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, @@ -626,6 +696,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, @@ -645,6 +716,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, @@ -664,6 +736,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: true, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: Some(72), context_window_used_tokens: None, @@ -683,6 +756,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: Some(123_456), @@ -702,6 +776,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: true, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, @@ -721,6 +796,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: true, steer_enabled: true, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, @@ -740,6 +816,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, steer_enabled: false, + collaboration_modes_enabled: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, diff --git a/codex-rs/tui2/src/bottom_pane/mod.rs b/codex-rs/tui2/src/bottom_pane/mod.rs index df9c0d84cd..83764d55b8 100644 --- a/codex-rs/tui2/src/bottom_pane/mod.rs +++ b/codex-rs/tui2/src/bottom_pane/mod.rs @@ -27,16 +27,24 @@ use bottom_pane_view::BottomPaneView; use codex_core::features::Features; use codex_core::skills::model::SkillMetadata; use codex_file_search::FileMatch; +use codex_protocol::user_input::TextElement; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use ratatui::buffer::Buffer; use ratatui::layout::Rect; +use ratatui::text::Line; use std::time::Duration; mod approval_overlay; pub(crate) use approval_overlay::ApprovalOverlay; pub(crate) use approval_overlay::ApprovalRequest; mod bottom_pane_view; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct LocalImageAttachment { + pub(crate) placeholder: String, + pub(crate) path: PathBuf, +} mod chat_composer; mod chat_composer_history; mod command_popup; @@ -180,6 +188,11 @@ impl BottomPane { self.composer.set_steer_enabled(enabled); } + pub fn set_collaboration_modes_enabled(&mut self, enabled: bool) { + self.composer.set_collaboration_modes_enabled(enabled); + self.request_redraw(); + } + pub fn status_widget(&self) -> Option<&StatusIndicatorWidget> { self.status.as_ref() } @@ -301,8 +314,14 @@ impl BottomPane { } /// Replace the composer text with `text`. - pub(crate) fn set_composer_text(&mut self, text: String) { - self.composer.set_text_content(text); + pub(crate) fn set_composer_text( + &mut self, + text: String, + text_elements: Vec, + local_image_paths: Vec, + ) { + self.composer + .set_text_content(text, text_elements, local_image_paths); self.request_redraw(); } @@ -326,6 +345,19 @@ impl BottomPane { self.composer.current_text() } + pub(crate) fn composer_text_elements(&self) -> Vec { + self.composer.text_elements() + } + + pub(crate) fn composer_local_images(&self) -> Vec { + self.composer.local_images() + } + + #[cfg(test)] + pub(crate) fn composer_local_image_paths(&self) -> Vec { + self.composer.local_image_paths() + } + /// Update the status indicator header (defaults to "Working") and details below it. /// /// Passing `None` clears any existing details. No-ops if the status indicator is not active. @@ -500,6 +532,23 @@ impl BottomPane { self.request_redraw(); } + pub(crate) fn flash_footer_hint(&mut self, line: Line<'static>, duration: Duration) { + self.composer.show_footer_flash(line, duration); + let frame_requester = self.frame_requester.clone(); + if let Ok(handle) = tokio::runtime::Handle::try_current() { + handle.spawn(async move { + tokio::time::sleep(duration).await; + frame_requester.schedule_frame(); + }); + } else { + std::thread::spawn(move || { + std::thread::sleep(duration); + frame_requester.schedule_frame(); + }); + } + self.request_redraw(); + } + pub(crate) fn composer_is_empty(&self) -> bool { self.composer.is_empty() } @@ -619,10 +668,18 @@ impl BottomPane { } } + #[cfg(test)] pub(crate) fn take_recent_submission_images(&mut self) -> Vec { self.composer.take_recent_submission_images() } + pub(crate) fn take_recent_submission_images_with_placeholders( + &mut self, + ) -> Vec { + self.composer + .take_recent_submission_images_with_placeholders() + } + fn as_renderable(&'_ self) -> RenderableItem<'_> { if let Some(view) = self.active_view() { RenderableItem::Borrowed(view) diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap new file mode 100644 index 0000000000..78af40d9f3 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap @@ -0,0 +1,10 @@ +--- +source: tui2/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" / 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 " +" shift + tab to change mode " +" 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 903ebe9f82..7c39379c9f 100644 --- a/codex-rs/tui2/src/bottom_pane/textarea.rs +++ b/codex-rs/tui2/src/bottom_pane/textarea.rs @@ -1,4 +1,6 @@ use crate::key_hint::is_altgr; +use codex_protocol::user_input::ByteRange; +use codex_protocol::user_input::TextElement as UserTextElement; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; @@ -60,10 +62,33 @@ impl TextArea { } } - pub fn set_text(&mut self, text: &str) { + /// Replace the textarea text and clear any existing text elements. + pub fn set_text_clearing_elements(&mut self, text: &str) { + self.set_text_inner(text, None); + } + + /// Replace the textarea text and set the provided text elements. + pub fn set_text_with_elements(&mut self, text: &str, elements: &[UserTextElement]) { + self.set_text_inner(text, Some(elements)); + } + + fn set_text_inner(&mut self, text: &str, elements: Option<&[UserTextElement]>) { self.text = text.to_string(); self.cursor_pos = self.cursor_pos.clamp(0, self.text.len()); self.elements.clear(); + if let Some(elements) = elements { + for elem in elements { + let mut start = elem.byte_range.start.min(self.text.len()); + let mut end = elem.byte_range.end.min(self.text.len()); + start = self.clamp_pos_to_char_boundary(start); + end = self.clamp_pos_to_char_boundary(end); + if start >= end { + continue; + } + self.elements.push(TextElement { range: start..end }); + } + self.elements.sort_by_key(|e| e.range.start); + } self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos); self.wrap_cache.replace(None); self.preferred_col = None; @@ -722,6 +747,22 @@ impl TextArea { .collect() } + pub fn text_elements(&self) -> Vec { + self.elements + .iter() + .map(|e| { + let placeholder = self.text.get(e.range.clone()).map(str::to_string); + UserTextElement::new( + ByteRange { + start: e.range.start, + end: e.range.end, + }, + placeholder, + ) + }) + .collect() + } + pub fn element_payload_starting_at(&self, pos: usize) -> Option { let pos = pos.min(self.text.len()); let elem = self.elements.iter().find(|e| e.range.start == pos)?; @@ -1251,7 +1292,7 @@ mod tests { let mut t = TextArea::new(); t.insert_str("abcd"); t.set_cursor(1); - t.set_text("你"); + t.set_text_clearing_elements("你"); assert_eq!(t.cursor(), 0); t.insert_str("a"); assert_eq!(t.text(), "a你"); @@ -1933,7 +1974,7 @@ mod tests { for _ in 0..base_len { base.push_str(&rand_grapheme(&mut rng)); } - ta.set_text(&base); + ta.set_text_clearing_elements(&base); // Choose a valid char boundary for initial cursor let mut boundaries: Vec = vec![0]; boundaries.extend(ta.text().char_indices().map(|(i, _)| i).skip(1)); diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index 5facd756f7..23c6195aca 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -90,7 +90,11 @@ use codex_core::skills::model::SkillMetadata; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; use codex_protocol::approvals::ElicitationRequestEvent; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::Settings; +use codex_protocol::models::local_image_label_text; use codex_protocol::parse_command::ParsedCommand; +use codex_protocol::user_input::TextElement; use codex_protocol::user_input::UserInput; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -122,6 +126,7 @@ use crate::bottom_pane::BottomPaneParams; use crate::bottom_pane::CancellationEvent; use crate::bottom_pane::DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED; use crate::bottom_pane::InputResult; +use crate::bottom_pane::LocalImageAttachment; use crate::bottom_pane::QUIT_SHORTCUT_TIMEOUT; use crate::bottom_pane::SelectionAction; use crate::bottom_pane::SelectionItem; @@ -130,6 +135,7 @@ use crate::bottom_pane::custom_prompt_view::CustomPromptView; use crate::bottom_pane::popup_consts::standard_popup_hint_line; use crate::clipboard_paste::paste_image_to_temp_png; use crate::collab; +use crate::collaboration_modes; use crate::diff_render::display_path_for; use crate::exec_cell::CommandOutput; use crate::exec_cell::ExecCell; @@ -298,8 +304,7 @@ pub(crate) struct ChatWidgetInit { pub(crate) config: Config, pub(crate) frame_requester: FrameRequester, pub(crate) app_event_tx: AppEventSender, - pub(crate) initial_prompt: Option, - pub(crate) initial_images: Vec, + pub(crate) initial_user_message: Option, pub(crate) enhanced_keys_supported: bool, pub(crate) auth_manager: Arc, pub(crate) models_manager: Arc, @@ -344,7 +349,12 @@ pub(crate) struct ChatWidget { /// where the overlay may briefly treat new tail content as already cached. active_cell_revision: u64, config: Config, - model: Option, + /// Stored collaboration mode with model and reasoning effort. + /// + /// When collaboration modes feature is enabled, this is initialized to the first preset. + /// When disabled, this is Custom. The model and reasoning effort are stored here instead of + /// being read from config or current_model. + stored_collaboration_mode: CollaborationMode, auth_manager: Arc, models_manager: Arc, session_header: SessionHeader, @@ -383,6 +393,7 @@ pub(crate) struct ChatWidget { // Previous status header to restore after a transient stream retry. retry_status_header: Option, conversation_id: Option, + forked_from: Option, frame_requester: FrameRequester, // Whether to include the initial welcome banner on session configured show_welcome_banner: bool, @@ -448,16 +459,19 @@ pub(crate) struct ActiveCellTranscriptKey { pub(crate) animation_tick: Option, } -struct UserMessage { +pub(crate) struct UserMessage { text: String, - image_paths: Vec, + local_images: Vec, + text_elements: Vec, } impl From for UserMessage { fn from(text: String) -> Self { Self { text, - image_paths: Vec::new(), + local_images: Vec::new(), + // Plain text conversion has no UI element ranges. + text_elements: Vec::new(), } } } @@ -466,16 +480,107 @@ impl From<&str> for UserMessage { fn from(text: &str) -> Self { Self { text: text.to_string(), - image_paths: Vec::new(), + local_images: Vec::new(), + // Plain text conversion has no UI element ranges. + text_elements: Vec::new(), } } } -fn create_initial_user_message(text: String, image_paths: Vec) -> Option { - if text.is_empty() && image_paths.is_empty() { +pub(crate) fn create_initial_user_message( + text: Option, + local_image_paths: Vec, + text_elements: Vec, +) -> Option { + let text = text.unwrap_or_default(); + if text.is_empty() && local_image_paths.is_empty() { None } else { - Some(UserMessage { text, image_paths }) + let local_images = local_image_paths + .into_iter() + .enumerate() + .map(|(idx, path)| LocalImageAttachment { + placeholder: local_image_label_text(idx + 1), + path, + }) + .collect(); + Some(UserMessage { + text, + local_images, + text_elements, + }) + } +} + +// When merging multiple queued drafts (e.g., after interrupt), each draft starts numbering +// its attachments at [Image #1]. Reassign placeholder labels based on the attachment list so +// the combined local_image_paths order matches the labels, even if placeholders were moved +// in the text (e.g., [Image #2] appearing before [Image #1]). +fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize) -> UserMessage { + let UserMessage { + text, + text_elements, + local_images, + } = message; + if local_images.is_empty() { + return UserMessage { + text, + text_elements, + local_images, + }; + } + + let mut mapping: HashMap = HashMap::new(); + let mut remapped_images = Vec::new(); + for attachment in local_images { + let new_placeholder = local_image_label_text(*next_label); + *next_label += 1; + mapping.insert(attachment.placeholder.clone(), new_placeholder.clone()); + remapped_images.push(LocalImageAttachment { + placeholder: new_placeholder, + path: attachment.path, + }); + } + + let mut elements = text_elements; + elements.sort_by_key(|elem| elem.byte_range.start); + + let mut cursor = 0usize; + let mut rebuilt = String::new(); + let mut rebuilt_elements = Vec::new(); + for mut elem in elements { + let start = elem.byte_range.start.min(text.len()); + let end = elem.byte_range.end.min(text.len()); + if let Some(segment) = text.get(cursor..start) { + rebuilt.push_str(segment); + } + + let original = text.get(start..end).unwrap_or(""); + let placeholder = elem.placeholder(&text); + let replacement = placeholder + .and_then(|ph| mapping.get(ph)) + .map(String::as_str) + .unwrap_or(original); + + let elem_start = rebuilt.len(); + rebuilt.push_str(replacement); + let elem_end = rebuilt.len(); + + if let Some(remapped) = placeholder.and_then(|ph| mapping.get(ph)) { + elem.set_placeholder(Some(remapped.clone())); + } + elem.byte_range = (elem_start..elem_end).into(); + rebuilt_elements.push(elem); + cursor = end; + } + if let Some(segment) = text.get(cursor..) { + rebuilt.push_str(segment); + } + + UserMessage { + text: rebuilt, + local_images: remapped_images, + text_elements: rebuilt_elements, } } @@ -522,11 +627,21 @@ impl ChatWidget { .set_history_metadata(event.history_log_id, event.history_entry_count); self.set_skills(None); self.conversation_id = Some(event.session_id); + self.forked_from = event.forked_from_id; self.current_rollout_path = Some(event.rollout_path.clone()); let initial_messages = event.initial_messages.clone(); let model_for_header = event.model.clone(); - self.model = Some(model_for_header.clone()); self.session_header.set_model(&model_for_header); + // Only update stored collaboration settings when collaboration modes are disabled. + // When enabled, we preserve the selected variant (Plan/Pair/Execute/Custom) and its + // instructions as-is; the session configured event should not override it. + if !self.collaboration_modes_enabled() { + self.stored_collaboration_mode = self.stored_collaboration_mode.with_updates( + Some(model_for_header.clone()), + Some(event.reasoning_effort), + None, + ); + } let session_info_cell = history_cell::new_session_info( &self.config, &model_for_header, @@ -542,7 +657,7 @@ impl ChatWidget { self.submit_op(Op::ListCustomPrompts); self.submit_op(Op::ListSkills { cwds: Vec::new(), - force_reload: false, + force_reload: true, }); if let Some(user_message) = self.initial_user_message.take() { self.submit_user_message(user_message); @@ -770,7 +885,7 @@ impl ChatWidget { if high_usage && !self.rate_limit_switch_prompt_hidden() - && self.current_model() != Some(NUDGE_MODEL_SLUG) + && self.current_model() != NUDGE_MODEL_SLUG && !matches!( self.rate_limit_switch_prompt, RateLimitSwitchPromptState::Shown @@ -904,31 +1019,76 @@ impl ChatWidget { )); } - // If any messages were queued during the task, restore them into the composer. - if !self.queued_user_messages.is_empty() { - let queued_text = self - .queued_user_messages + if let Some(combined) = self.drain_queued_messages_for_restore() { + let combined_local_image_paths = combined + .local_images .iter() - .map(|m| m.text.clone()) - .collect::>() - .join("\n"); - let existing_text = self.bottom_pane.composer_text(); - let combined = if existing_text.is_empty() { - queued_text - } else if queued_text.is_empty() { - existing_text - } else { - format!("{queued_text}\n{existing_text}") - }; - self.bottom_pane.set_composer_text(combined); - // Clear the queue and update the status indicator list. - self.queued_user_messages.clear(); + .map(|img| img.path.clone()) + .collect(); + self.bottom_pane.set_composer_text( + combined.text, + combined.text_elements, + combined_local_image_paths, + ); self.refresh_queued_user_messages(); } self.request_redraw(); } + /// Merge queued drafts (plus the current composer state) into a single message for restore. + /// + /// Each queued draft numbers attachments from `[Image #1]`. When we concatenate drafts, we + /// must renumber placeholders in a stable order so the merged attachment list stays aligned + /// with the labels embedded in text. This helper drains the queue, remaps placeholders, and + /// fixes text element byte ranges as content is appended. Returns `None` when there is nothing + /// to restore. + fn drain_queued_messages_for_restore(&mut self) -> Option { + if self.queued_user_messages.is_empty() { + return None; + } + + let existing_message = UserMessage { + text: self.bottom_pane.composer_text(), + text_elements: self.bottom_pane.composer_text_elements(), + local_images: self.bottom_pane.composer_local_images(), + }; + + let mut to_merge: Vec = self.queued_user_messages.drain(..).collect(); + if !existing_message.text.is_empty() || !existing_message.local_images.is_empty() { + to_merge.push(existing_message); + } + + let mut combined = UserMessage { + text: String::new(), + text_elements: Vec::new(), + local_images: Vec::new(), + }; + let mut combined_offset = 0usize; + let mut next_image_label = 1usize; + + for (idx, message) in to_merge.into_iter().enumerate() { + if idx > 0 { + combined.text.push('\n'); + combined_offset += 1; + } + let message = remap_placeholders_for_message(message, &mut next_image_label); + let base = combined_offset; + combined.text.push_str(&message.text); + combined_offset += message.text.len(); + combined + .text_elements + .extend(message.text_elements.into_iter().map(|mut elem| { + elem.byte_range.start += base; + elem.byte_range.end += base; + elem + })); + combined.local_images.extend(message.local_images); + } + + Some(combined) + } + fn on_plan_update(&mut self, update: UpdatePlanArgs) { self.add_to_history(history_cell::new_plan_update(update)); } @@ -1432,8 +1592,7 @@ impl ChatWidget { config, frame_requester, app_event_tx, - initial_prompt, - initial_images, + initial_user_message, enhanced_keys_supported, auth_manager, models_manager, @@ -1441,15 +1600,14 @@ impl ChatWidget { is_first_run, model, } = common; - let mut config = config; let model = model.filter(|m| !m.trim().is_empty()); + let mut config = config; config.model = model.clone(); let mut rng = rand::rng(); let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string(); let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), thread_manager); - let model_for_header = config - .model + let model_for_header = model .clone() .unwrap_or_else(|| DEFAULT_MODEL_DISPLAY_NAME.to_string()); let active_cell = if model.is_none() { @@ -1458,6 +1616,22 @@ impl ChatWidget { None }; + let stored_collaboration_mode = if config.features.enabled(Feature::CollaborationModes) { + collaboration_modes::default_mode(models_manager.as_ref()).unwrap_or_else(|| { + CollaborationMode::Custom(Settings { + model: model_for_header.clone(), + reasoning_effort: None, + developer_instructions: None, + }) + }) + } else { + CollaborationMode::Custom(Settings { + model: model_for_header.clone(), + reasoning_effort: None, + developer_instructions: None, + }) + }; + let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), @@ -1475,14 +1649,11 @@ impl ChatWidget { active_cell, active_cell_revision: 0, config, - model, + stored_collaboration_mode, auth_manager, models_manager, session_header: SessionHeader::new(model_for_header), - initial_user_message: create_initial_user_message( - initial_prompt.unwrap_or_default(), - initial_images, - ), + initial_user_message, token_info: None, rate_limit_snapshot: None, plan_type: None, @@ -1502,6 +1673,7 @@ impl ChatWidget { current_status_header: String::from("Working"), retry_status_header: None, conversation_id: None, + forked_from: None, queued_user_messages: VecDeque::new(), show_welcome_banner: is_first_run, suppress_session_configured_redraw: false, @@ -1521,6 +1693,9 @@ impl ChatWidget { widget .bottom_pane .set_steer_enabled(widget.config.features.enabled(Feature::Steer)); + widget.bottom_pane.set_collaboration_modes_enabled( + widget.config.features.enabled(Feature::CollaborationModes), + ); widget } @@ -1532,11 +1707,10 @@ impl ChatWidget { session_configured: codex_core::protocol::SessionConfiguredEvent, ) -> Self { let ChatWidgetInit { - mut config, + config, frame_requester, app_event_tx, - initial_prompt, - initial_images, + initial_user_message, enhanced_keys_supported, auth_manager, models_manager, @@ -1545,7 +1719,7 @@ impl ChatWidget { .. } = common; let model = model.filter(|m| !m.trim().is_empty()); - config.model = model.clone(); + let config = config; let mut rng = rand::rng(); let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string(); @@ -1554,6 +1728,22 @@ impl ChatWidget { let codex_op_tx = spawn_agent_from_existing(conversation, session_configured, app_event_tx.clone()); + let stored_collaboration_mode = if config.features.enabled(Feature::CollaborationModes) { + collaboration_modes::default_mode(models_manager.as_ref()).unwrap_or_else(|| { + CollaborationMode::Custom(Settings { + model: header_model.clone(), + reasoning_effort: None, + developer_instructions: None, + }) + }) + } else { + CollaborationMode::Custom(Settings { + model: header_model.clone(), + reasoning_effort: None, + developer_instructions: None, + }) + }; + let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), @@ -1571,14 +1761,11 @@ impl ChatWidget { active_cell: None, active_cell_revision: 0, config, - model: Some(header_model.clone()), + stored_collaboration_mode, auth_manager, models_manager, session_header: SessionHeader::new(header_model), - initial_user_message: create_initial_user_message( - initial_prompt.unwrap_or_default(), - initial_images, - ), + initial_user_message, token_info: None, rate_limit_snapshot: None, plan_type: None, @@ -1598,6 +1785,7 @@ impl ChatWidget { current_status_header: String::from("Working"), retry_status_header: None, conversation_id: None, + forked_from: None, queued_user_messages: VecDeque::new(), show_welcome_banner: false, suppress_session_configured_redraw: true, @@ -1617,6 +1805,9 @@ impl ChatWidget { widget .bottom_pane .set_steer_enabled(widget.config.features.enabled(Feature::Steer)); + widget.bottom_pane.set_collaboration_modes_enabled( + widget.config.features.enabled(Feature::CollaborationModes), + ); widget } @@ -1681,6 +1872,16 @@ impl ChatWidget { } match key_event { + KeyEvent { + code: KeyCode::BackTab, + kind: KeyEventKind::Press, + .. + } if self.collaboration_modes_enabled() + && !self.bottom_pane.is_task_running() + && self.bottom_pane.no_modal_or_popup_active() => + { + self.cycle_collaboration_mode(); + } KeyEvent { code: KeyCode::Up, modifiers: KeyModifiers::ALT, @@ -1689,46 +1890,64 @@ impl ChatWidget { } if !self.queued_user_messages.is_empty() => { // Prefer the most recently queued item. if let Some(user_message) = self.queued_user_messages.pop_back() { - self.bottom_pane.set_composer_text(user_message.text); + let local_image_paths = user_message + .local_images + .iter() + .map(|img| img.path.clone()) + .collect(); + self.bottom_pane.set_composer_text( + user_message.text, + user_message.text_elements, + local_image_paths, + ); self.refresh_queued_user_messages(); self.request_redraw(); } } - _ => { - match self.bottom_pane.handle_key_event(key_event) { - InputResult::Submitted(text) => { - // Enter always sends messages immediately (bypasses queue check) - // Clear any reasoning status header when submitting a new message + _ => match self.bottom_pane.handle_key_event(key_event) { + InputResult::Submitted { + text, + text_elements, + } => { + let user_message = UserMessage { + text, + local_images: self + .bottom_pane + .take_recent_submission_images_with_placeholders(), + text_elements, + }; + if self.is_session_configured() { + // Submitted is only emitted when steer is enabled (Enter sends immediately). + // Reset any reasoning header only when we are actually submitting a turn. 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(), - }; - if !self.is_session_configured() { - self.queue_user_message(user_message); - } else { - 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(), - }; + self.submit_user_message(user_message); + } else { self.queue_user_message(user_message); } - InputResult::Command(cmd) => { - self.dispatch_command(cmd); - } - InputResult::CommandWithArgs(cmd, args) => { - self.dispatch_command_with_args(cmd, args); - } - InputResult::None => {} } - } + InputResult::Queued { + text, + text_elements, + } => { + let user_message = UserMessage { + text, + local_images: self + .bottom_pane + .take_recent_submission_images_with_placeholders(), + text_elements, + }; + self.queue_user_message(user_message); + } + InputResult::Command(cmd) => { + self.dispatch_command(cmd); + } + InputResult::CommandWithArgs(cmd, args) => { + self.dispatch_command_with_args(cmd, args); + } + InputResult::None => {} + }, } } @@ -1769,7 +1988,7 @@ impl ChatWidget { self.app_event_tx.send(AppEvent::OpenResumePicker); } SlashCommand::Fork => { - self.app_event_tx.send(AppEvent::OpenForkPicker); + self.app_event_tx.send(AppEvent::ForkCurrentSession); } SlashCommand::Init => { let init_target = self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME); @@ -1793,6 +2012,11 @@ impl ChatWidget { SlashCommand::Model => { self.open_model_popup(); } + SlashCommand::Collab => { + if self.collaboration_modes_enabled() { + self.open_collaboration_modes_popup(); + } + } SlashCommand::Approvals => { self.open_approvals_popup(); } @@ -1953,6 +2177,14 @@ impl ChatWidget { }, }); } + SlashCommand::Collab => { + if !self.collaboration_modes_enabled() { + return; + } + + let _ = trimmed; + self.open_collaboration_modes_popup(); + } _ => self.dispatch_command(cmd), } } @@ -2018,8 +2250,19 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { - let UserMessage { text, image_paths } = user_message; - if text.is_empty() && image_paths.is_empty() { + if !self.is_session_configured() { + tracing::warn!("cannot submit user message before session is configured; queueing"); + self.queued_user_messages.push_front(user_message); + self.refresh_queued_user_messages(); + return; + } + + let UserMessage { + text, + local_images, + text_elements, + } = user_message; + if text.is_empty() && local_images.is_empty() { return; } @@ -2043,15 +2286,16 @@ impl ChatWidget { return; } - for path in image_paths { - items.push(UserInput::LocalImage { path }); + for image in &local_images { + items.push(UserInput::LocalImage { + path: image.path.clone(), + }); } if !text.is_empty() { - // TODO: Thread text element ranges from the composer input. Empty keeps old behavior. items.push(UserInput::Text { text: text.clone(), - text_elements: Vec::new(), + text_elements: text_elements.clone(), }); } @@ -2065,14 +2309,28 @@ impl ChatWidget { } } - self.codex_op_tx - .send(Op::UserInput { - items, - final_output_json_schema: None, - }) - .unwrap_or_else(|e| { - tracing::error!("failed to send message: {e}"); - }); + let op = Op::UserTurn { + items, + cwd: self.config.cwd.clone(), + approval_policy: self.config.approval_policy.value(), + sandbox_policy: self.config.sandbox_policy.get().clone(), + model: self.stored_collaboration_mode.model().to_string(), + effort: self.stored_collaboration_mode.reasoning_effort(), + summary: self.config.model_reasoning_summary, + final_output_json_schema: None, + collaboration_mode: self + .collaboration_modes_enabled() + .then(|| self.stored_collaboration_mode.clone()), + }; + + if !self.agent_turn_running { + self.agent_turn_running = true; + self.update_task_running_state(); + } + + self.codex_op_tx.send(op).unwrap_or_else(|e| { + tracing::error!("failed to send message: {e}"); + }); // Persist the text to cross-session message history. if !text.is_empty() { @@ -2085,7 +2343,12 @@ impl ChatWidget { // Only show the text portion in conversation history. if !text.is_empty() { - self.add_to_history(history_cell::new_user_prompt(text)); + let local_image_paths = local_images.into_iter().map(|img| img.path).collect(); + self.add_to_history(history_cell::new_user_prompt( + text, + text_elements, + local_image_paths, + )); } self.needs_final_message_separator = false; } @@ -2241,7 +2504,8 @@ impl ChatWidget { | EventMsg::ItemCompleted(_) | EventMsg::AgentMessageContentDelta(_) | EventMsg::ReasoningContentDelta(_) - | EventMsg::ReasoningRawContentDelta(_) => {} + | EventMsg::ReasoningRawContentDelta(_) + | EventMsg::RequestUserInput(_) => {} } } @@ -2295,10 +2559,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())); + if !event.message.trim().is_empty() { + self.add_to_history(history_cell::new_user_prompt( + event.message, + event.text_elements, + event.local_images, + )); } self.needs_final_message_separator = false; @@ -2394,16 +2660,20 @@ impl ChatWidget { let total_usage = token_info .map(|ti| &ti.total_token_usage) .unwrap_or(&default_usage); + let reasoning_effort_override = Some(self.stored_collaboration_mode.reasoning_effort()); self.add_to_history(crate::status::new_status_output( &self.config, self.auth_manager.as_ref(), token_info, total_usage, &self.conversation_id, + self.forked_from, self.rate_limit_snapshot.as_ref(), self.plan_type, Local::now(), self.model_display_name(), + self.collaboration_mode_label(), + reasoning_effort_override, )); } fn stop_rate_limit_poller(&mut self) { @@ -2487,6 +2757,7 @@ impl ChatWidget { model: Some(switch_model.clone()), effort: Some(Some(default_effort)), summary: None, + collaboration_mode: None, })); tx.send(AppEvent::UpdateModel(switch_model.clone())); tx.send(AppEvent::UpdateReasoningEffort(Some(default_effort))); @@ -2577,7 +2848,7 @@ impl ChatWidget { let current_model = self.current_model(); let current_label = presets .iter() - .find(|preset| Some(preset.model.as_str()) == current_model) + .find(|preset| preset.model.as_str() == current_model) .map(|preset| preset.display_name.to_string()) .unwrap_or_else(|| self.model_display_name().to_string()); @@ -2605,7 +2876,7 @@ impl ChatWidget { SelectionItem { name: preset.display_name.clone(), description, - is_current: Some(model.as_str()) == current_model, + is_current: model.as_str() == current_model, is_default: preset.is_default, actions, dismiss_on_select: true, @@ -2672,7 +2943,7 @@ impl ChatWidget { for preset in presets.into_iter() { let description = (!preset.description.is_empty()).then_some(preset.description.to_string()); - let is_current = Some(preset.model.as_str()) == self.current_model(); + let is_current = preset.model.as_str() == self.current_model(); let single_supported_effort = preset.supported_reasoning_efforts.len() == 1; let preset_for_action = preset.clone(); let actions: Vec = vec![Box::new(move |tx| { @@ -2704,6 +2975,49 @@ impl ChatWidget { }); } + pub(crate) fn open_collaboration_modes_popup(&mut self) { + let presets = self.models_manager.list_collaboration_modes(); + if presets.is_empty() { + self.add_info_message( + "No collaboration modes are available right now.".to_string(), + None, + ); + return; + } + + let items: Vec = presets + .into_iter() + .map(|preset| { + let name = match preset { + CollaborationMode::Plan(_) => "Plan", + CollaborationMode::PairProgramming(_) => "Pair Programming", + CollaborationMode::Execute(_) => "Execute", + CollaborationMode::Custom(_) => "Custom", + }; + let is_current = + collaboration_modes::same_variant(&self.stored_collaboration_mode, &preset); + let actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::UpdateCollaborationMode(preset.clone())); + })]; + SelectionItem { + name: name.to_string(), + is_current, + actions, + dismiss_on_select: true, + ..Default::default() + } + }) + .collect(); + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Select Collaboration Mode".to_string()), + subtitle: Some("Pick a collaboration preset.".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }); + } + fn model_selection_actions( model_for_action: String, effort_for_action: Option, @@ -2719,6 +3033,7 @@ impl ChatWidget { model: Some(model_for_action.clone()), effort: Some(effort_for_action), summary: None, + collaboration_mode: None, })); tx.send(AppEvent::UpdateModel(model_for_action.clone())); tx.send(AppEvent::UpdateReasoningEffort(effort_for_action)); @@ -2798,9 +3113,9 @@ impl ChatWidget { .or(Some(default_effort)); let model_slug = preset.model.to_string(); - let is_current_model = self.current_model() == Some(preset.model.as_str()); + let is_current_model = self.current_model() == preset.model.as_str(); let highlight_choice = if is_current_model { - self.config.model_reasoning_effort + self.stored_collaboration_mode.reasoning_effort() } else { default_choice }; @@ -2890,6 +3205,7 @@ impl ChatWidget { model: Some(model.clone()), effort: Some(effort), summary: None, + collaboration_mode: None, })); self.app_event_tx.send(AppEvent::UpdateModel(model.clone())); self.app_event_tx @@ -3035,6 +3351,7 @@ impl ChatWidget { model: None, effort: None, summary: None, + collaboration_mode: None, })); tx.send(AppEvent::UpdateAskForApprovalPolicy(approval)); tx.send(AppEvent::UpdateSandboxPolicy(sandbox_clone)); @@ -3575,6 +3892,21 @@ impl ChatWidget { if feature == Feature::Steer { self.bottom_pane.set_steer_enabled(enabled); } + if feature == Feature::CollaborationModes { + self.bottom_pane.set_collaboration_modes_enabled(enabled); + let settings = match &self.stored_collaboration_mode { + CollaborationMode::Plan(settings) + | CollaborationMode::PairProgramming(settings) + | CollaborationMode::Execute(settings) + | CollaborationMode::Custom(settings) => settings.clone(), + }; + self.stored_collaboration_mode = if enabled { + collaboration_modes::default_mode(self.models_manager.as_ref()) + .unwrap_or(CollaborationMode::Custom(settings)) + } else { + CollaborationMode::Custom(settings) + }; + } } pub(crate) fn set_full_access_warning_acknowledged(&mut self, acknowledged: bool) { @@ -3600,23 +3932,97 @@ impl ChatWidget { .unwrap_or(false) } - /// Set the reasoning effort in the widget's config copy. + /// Set the reasoning effort in the stored collaboration mode. pub(crate) fn set_reasoning_effort(&mut self, effort: Option) { - self.config.model_reasoning_effort = effort; + self.stored_collaboration_mode = + self.stored_collaboration_mode + .with_updates(None, Some(effort), None); } - /// Set the model in the widget's config copy. + /// Set the model in the widget's config copy and stored collaboration mode. pub(crate) fn set_model(&mut self, model: &str) { self.session_header.set_model(model); - self.model = Some(model.to_string()); + self.stored_collaboration_mode = + self.stored_collaboration_mode + .with_updates(Some(model.to_string()), None, None); } - fn current_model(&self) -> Option<&str> { - self.model.as_deref() + pub(crate) fn current_model(&self) -> &str { + self.stored_collaboration_mode.model() + } + + #[cfg(test)] + pub(crate) fn current_reasoning_effort(&self) -> Option { + self.stored_collaboration_mode.reasoning_effort() + } + + fn is_session_configured(&self) -> bool { + self.conversation_id.is_some() + } + + fn collaboration_modes_enabled(&self) -> bool { + self.config.features.enabled(Feature::CollaborationModes) } fn model_display_name(&self) -> &str { - self.model.as_deref().unwrap_or(DEFAULT_MODEL_DISPLAY_NAME) + let model = self.current_model(); + if model.is_empty() { + DEFAULT_MODEL_DISPLAY_NAME + } else { + model + } + } + + /// Get the label for the current collaboration mode. + fn collaboration_mode_label(&self) -> Option<&'static str> { + if !self.collaboration_modes_enabled() { + return None; + } + match &self.stored_collaboration_mode { + CollaborationMode::Plan(_) => Some("Plan"), + CollaborationMode::PairProgramming(_) => Some("Pair Programming"), + CollaborationMode::Execute(_) => Some("Execute"), + CollaborationMode::Custom(_) => None, + } + } + + /// Cycle to the next collaboration mode variant (Plan -> PairProgramming -> Execute -> Plan). + fn cycle_collaboration_mode(&mut self) { + if !self.collaboration_modes_enabled() { + return; + } + + if let Some(next_mode) = collaboration_modes::next_mode( + self.models_manager.as_ref(), + &self.stored_collaboration_mode, + ) { + self.set_collaboration_mode(next_mode); + } + } + + /// Update the stored collaboration mode. + /// + /// When collaboration modes are enabled, the current mode is attached to *every* + /// submission as `Op::UserTurn { collaboration_mode: Some(...) }`. + pub(crate) fn set_collaboration_mode(&mut self, mode: CollaborationMode) { + if !self.collaboration_modes_enabled() { + return; + } + + self.stored_collaboration_mode = mode; + + let label = self.collaboration_mode_label(); + if let Some(label) = label { + let flash = Line::from(vec![ + label.bold(), + " (".dim(), + key_hint::shift(KeyCode::Tab).into(), + " to change mode)".dim(), + ]); + const FLASH_DURATION: Duration = Duration::from_secs(2); + self.bottom_pane.flash_footer_hint(flash, FLASH_DURATION); + } + self.request_redraw(); } /// Build a placeholder header cell while the session is configuring. @@ -3806,8 +4212,14 @@ impl ChatWidget { } /// Replace the composer content with the provided text and reset cursor. - pub(crate) fn set_composer_text(&mut self, text: String) { - self.bottom_pane.set_composer_text(text); + pub(crate) fn set_composer_text( + &mut self, + text: String, + text_elements: Vec, + local_image_paths: Vec, + ) { + self.bottom_pane + .set_composer_text(text, text_elements, local_image_paths); } pub(crate) fn show_esc_backtrack_hint(&mut self) { @@ -4052,10 +4464,6 @@ impl ChatWidget { self.current_rollout_path.clone() } - fn is_session_configured(&self) -> bool { - self.conversation_id.is_some() - } - /// 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 diff --git a/codex-rs/tui2/src/chatwidget/tests.rs b/codex-rs/tui2/src/chatwidget/tests.rs index 22871c39d7..005b015a67 100644 --- a/codex-rs/tui2/src/chatwidget/tests.rs +++ b/codex-rs/tui2/src/chatwidget/tests.rs @@ -8,6 +8,8 @@ use super::*; use crate::app_event::AppEvent; use crate::app_event::ExitMode; use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::LocalImageAttachment; +use crate::history_cell::UserHistoryCell; use crate::test_backend::VT100Backend; use crate::tui::FrameRequester; use assert_matches::assert_matches; @@ -17,7 +19,6 @@ use codex_core::CodexAuth; use codex_core::config::Config; use codex_core::config::ConfigBuilder; use codex_core::config::Constrained; -#[cfg(target_os = "windows")] use codex_core::features::Feature; use codex_core::models_manager::manager::ModelsManager; use codex_core::protocol::AgentMessageDeltaEvent; @@ -57,6 +58,8 @@ use codex_core::protocol::ViewImageToolCallEvent; use codex_core::protocol::WarningEvent; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::parse_command::ParsedCommand; @@ -64,6 +67,8 @@ use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; use codex_protocol::protocol::CodexErrorInfo; +use codex_protocol::user_input::TextElement; +use codex_protocol::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -120,6 +125,7 @@ async fn resumed_initial_messages_render_history() { let rollout_file = NamedTempFile::new().unwrap(); let configured = codex_core::protocol::SessionConfiguredEvent { session_id: conversation_id, + forked_from_id: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -169,6 +175,359 @@ async fn resumed_initial_messages_render_history() { ); } +#[tokio::test] +async fn replayed_user_message_preserves_text_elements_and_local_images() { + let (mut chat, mut rx, _ops) = make_chatwidget_manual(None).await; + + let placeholder = "[Image #1]"; + let message = format!("{placeholder} replayed"); + let text_elements = vec![TextElement::new( + (0..placeholder.len()).into(), + Some(placeholder.to_string()), + )]; + let local_images = vec![PathBuf::from("/tmp/replay.png")]; + + let conversation_id = ThreadId::new(); + let rollout_file = NamedTempFile::new().unwrap(); + let configured = codex_core::protocol::SessionConfiguredEvent { + session_id: conversation_id, + forked_from_id: None, + model: "test-model".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: Some(ReasoningEffortConfig::default()), + history_log_id: 0, + history_entry_count: 0, + initial_messages: Some(vec![EventMsg::UserMessage(UserMessageEvent { + message: message.clone(), + images: None, + text_elements: text_elements.clone(), + local_images: local_images.clone(), + })]), + rollout_path: rollout_file.path().to_path_buf(), + }; + + chat.handle_codex_event(Event { + id: "initial".into(), + msg: EventMsg::SessionConfigured(configured), + }); + + let mut user_cell = None; + while let Ok(ev) = rx.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = ev + && let Some(cell) = cell.as_any().downcast_ref::() + { + user_cell = Some(( + cell.message.clone(), + cell.text_elements.clone(), + cell.local_image_paths.clone(), + )); + break; + } + } + + let (stored_message, stored_elements, stored_images) = + user_cell.expect("expected a replayed user history cell"); + assert_eq!(stored_message, message); + assert_eq!(stored_elements, text_elements); + assert_eq!(stored_images, local_images); +} + +#[tokio::test] +async fn submission_preserves_text_elements_and_local_images() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + + let conversation_id = ThreadId::new(); + let rollout_file = NamedTempFile::new().unwrap(); + let configured = codex_core::protocol::SessionConfiguredEvent { + session_id: conversation_id, + forked_from_id: None, + model: "test-model".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: Some(ReasoningEffortConfig::default()), + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + rollout_path: rollout_file.path().to_path_buf(), + }; + chat.handle_codex_event(Event { + id: "initial".into(), + msg: EventMsg::SessionConfigured(configured), + }); + drain_insert_history(&mut rx); + + let placeholder = "[Image #1]"; + let text = format!("{placeholder} submit"); + let text_elements = vec![TextElement::new( + (0..placeholder.len()).into(), + Some(placeholder.to_string()), + )]; + let local_images = vec![PathBuf::from("/tmp/submitted.png")]; + + chat.bottom_pane + .set_composer_text(text.clone(), text_elements.clone(), local_images.clone()); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let items = match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => items, + other => panic!("expected Op::UserTurn, got {other:?}"), + }; + assert_eq!(items.len(), 2); + assert_eq!( + items[0], + UserInput::LocalImage { + path: local_images[0].clone() + } + ); + assert_eq!( + items[1], + UserInput::Text { + text: text.clone(), + text_elements: text_elements.clone(), + } + ); + + let mut user_cell = None; + while let Ok(ev) = rx.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = ev + && let Some(cell) = cell.as_any().downcast_ref::() + { + user_cell = Some(( + cell.message.clone(), + cell.text_elements.clone(), + cell.local_image_paths.clone(), + )); + break; + } + } + + let (stored_message, stored_elements, stored_images) = + user_cell.expect("expected submitted user history cell"); + assert_eq!(stored_message, text); + assert_eq!(stored_elements, text_elements); + assert_eq!(stored_images, local_images); +} + +#[tokio::test] +async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + + let first_placeholder = "[Image #1]"; + let first_text = format!("{first_placeholder} first"); + let first_elements = vec![TextElement::new( + (0..first_placeholder.len()).into(), + Some(first_placeholder.to_string()), + )]; + let first_images = [PathBuf::from("/tmp/first.png")]; + + let second_placeholder = "[Image #1]"; + let second_text = format!("{second_placeholder} second"); + let second_elements = vec![TextElement::new( + (0..second_placeholder.len()).into(), + Some(second_placeholder.to_string()), + )]; + let second_images = [PathBuf::from("/tmp/second.png")]; + + let existing_placeholder = "[Image #1]"; + let existing_text = format!("{existing_placeholder} existing"); + let existing_elements = vec![TextElement::new( + (0..existing_placeholder.len()).into(), + Some(existing_placeholder.to_string()), + )]; + let existing_images = vec![PathBuf::from("/tmp/existing.png")]; + + chat.queued_user_messages.push_back(UserMessage { + text: first_text, + local_images: vec![LocalImageAttachment { + placeholder: first_placeholder.to_string(), + path: first_images[0].clone(), + }], + text_elements: first_elements, + }); + chat.queued_user_messages.push_back(UserMessage { + text: second_text, + local_images: vec![LocalImageAttachment { + placeholder: second_placeholder.to_string(), + path: second_images[0].clone(), + }], + text_elements: second_elements, + }); + chat.refresh_queued_user_messages(); + + chat.bottom_pane + .set_composer_text(existing_text, existing_elements, existing_images.clone()); + + // When interrupted, queued messages are merged into the composer; image placeholders + // must be renumbered to match the combined local image list. + chat.handle_codex_event(Event { + id: "interrupt".into(), + msg: EventMsg::TurnAborted(codex_core::protocol::TurnAbortedEvent { + reason: TurnAbortReason::Interrupted, + }), + }); + + let first = "[Image #1] first".to_string(); + let second = "[Image #2] second".to_string(); + let third = "[Image #3] existing".to_string(); + let expected_text = format!("{first}\n{second}\n{third}"); + assert_eq!(chat.bottom_pane.composer_text(), expected_text); + + let first_start = 0; + let second_start = first.len() + 1; + let third_start = second_start + second.len() + 1; + let expected_elements = vec![ + TextElement::new( + (first_start..first_start + "[Image #1]".len()).into(), + Some("[Image #1]".to_string()), + ), + TextElement::new( + (second_start..second_start + "[Image #2]".len()).into(), + Some("[Image #2]".to_string()), + ), + TextElement::new( + (third_start..third_start + "[Image #3]".len()).into(), + Some("[Image #3]".to_string()), + ), + ]; + assert_eq!(chat.bottom_pane.composer_text_elements(), expected_elements); + assert_eq!( + chat.bottom_pane.composer_local_image_paths(), + vec![ + first_images[0].clone(), + second_images[0].clone(), + existing_images[0].clone(), + ] + ); +} + +#[tokio::test] +async fn remap_placeholders_uses_attachment_labels() { + let placeholder_one = "[Image #1]"; + let placeholder_two = "[Image #2]"; + let text = format!("{placeholder_two} before {placeholder_one}"); + let elements = vec![ + TextElement::new( + (0..placeholder_two.len()).into(), + Some(placeholder_two.to_string()), + ), + TextElement::new( + ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(), + Some(placeholder_one.to_string()), + ), + ]; + + let attachments = vec![ + LocalImageAttachment { + placeholder: placeholder_one.to_string(), + path: PathBuf::from("/tmp/one.png"), + }, + LocalImageAttachment { + placeholder: placeholder_two.to_string(), + path: PathBuf::from("/tmp/two.png"), + }, + ]; + let message = UserMessage { + text, + text_elements: elements, + local_images: attachments, + }; + let mut next_label = 3usize; + let remapped = remap_placeholders_for_message(message, &mut next_label); + + assert_eq!(remapped.text, "[Image #4] before [Image #3]"); + assert_eq!( + remapped.text_elements, + vec![ + TextElement::new( + (0.."[Image #4]".len()).into(), + Some("[Image #4]".to_string()), + ), + TextElement::new( + ("[Image #4] before ".len().."[Image #4] before [Image #3]".len()).into(), + Some("[Image #3]".to_string()), + ), + ] + ); + assert_eq!( + remapped.local_images, + vec![ + LocalImageAttachment { + placeholder: "[Image #3]".to_string(), + path: PathBuf::from("/tmp/one.png"), + }, + LocalImageAttachment { + placeholder: "[Image #4]".to_string(), + path: PathBuf::from("/tmp/two.png"), + }, + ] + ); +} + +#[tokio::test] +async fn remap_placeholders_uses_byte_ranges_when_placeholder_missing() { + let placeholder_one = "[Image #1]"; + let placeholder_two = "[Image #2]"; + let text = format!("{placeholder_two} before {placeholder_one}"); + let elements = vec![ + TextElement::new((0..placeholder_two.len()).into(), None), + TextElement::new( + ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(), + None, + ), + ]; + + let attachments = vec![ + LocalImageAttachment { + placeholder: placeholder_one.to_string(), + path: PathBuf::from("/tmp/one.png"), + }, + LocalImageAttachment { + placeholder: placeholder_two.to_string(), + path: PathBuf::from("/tmp/two.png"), + }, + ]; + let message = UserMessage { + text, + text_elements: elements, + local_images: attachments, + }; + let mut next_label = 3usize; + let remapped = remap_placeholders_for_message(message, &mut next_label); + + assert_eq!(remapped.text, "[Image #4] before [Image #3]"); + assert_eq!( + remapped.text_elements, + vec![ + TextElement::new( + (0.."[Image #4]".len()).into(), + Some("[Image #4]".to_string()), + ), + TextElement::new( + ("[Image #4] before ".len().."[Image #4] before [Image #3]".len()).into(), + Some("[Image #3]".to_string()), + ), + ] + ); + assert_eq!( + remapped.local_images, + vec![ + LocalImageAttachment { + placeholder: "[Image #3]".to_string(), + path: PathBuf::from("/tmp/one.png"), + }, + LocalImageAttachment { + placeholder: "[Image #4]".to_string(), + path: PathBuf::from("/tmp/two.png"), + }, + ] + ); +} + /// Entering review mode uses the hint provided by the review request. #[tokio::test] async fn entered_review_mode_uses_request_hint() { @@ -329,23 +688,23 @@ async fn helpers_are_available_and_do_not_panic() { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let cfg = test_config().await; + let model = cfg.model.clone(); let thread_manager = Arc::new(ThreadManager::with_models_provider( CodexAuth::from_api_key("test"), cfg.model_provider.clone(), )); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); let init = ChatWidgetInit { - config: cfg.clone(), + config: cfg, frame_requester: FrameRequester::test_dummy(), app_event_tx: tx, - initial_prompt: None, - initial_images: Vec::new(), + initial_user_message: None, enhanced_keys_supported: false, auth_manager, models_manager: thread_manager.get_models_manager(), feedback: codex_feedback::CodexFeedback::new(), is_first_run: true, - model: cfg.model, + model, }; let mut w = ChatWidget::new(init, thread_manager); // Basic construction sanity. @@ -381,8 +740,27 @@ async fn make_chatwidget_manual( skills: None, }); bottom.set_steer_enabled(true); + bottom.set_collaboration_modes_enabled(cfg.features.enabled(Feature::CollaborationModes)); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); let codex_home = cfg.codex_home.clone(); + let models_manager = Arc::new(ModelsManager::new(codex_home, auth_manager.clone())); + let collaboration_modes_enabled = cfg.features.enabled(Feature::CollaborationModes); + let reasoning_effort = None; + let stored_collaboration_mode = if collaboration_modes_enabled { + collaboration_modes::default_mode(models_manager.as_ref()).unwrap_or_else(|| { + CollaborationMode::Custom(Settings { + model: resolved_model.clone(), + reasoning_effort, + developer_instructions: None, + }) + }) + } else { + CollaborationMode::Custom(Settings { + model: resolved_model.clone(), + reasoning_effort, + developer_instructions: None, + }) + }; let widget = ChatWidget { app_event_tx, codex_op_tx: op_tx, @@ -390,9 +768,9 @@ async fn make_chatwidget_manual( active_cell: None, active_cell_revision: 0, config: cfg, - model: Some(resolved_model.clone()), - auth_manager: auth_manager.clone(), - models_manager: Arc::new(ModelsManager::new(codex_home, auth_manager)), + stored_collaboration_mode, + auth_manager, + models_manager, session_header: SessionHeader::new(resolved_model), initial_user_message: None, token_info: None, @@ -414,6 +792,7 @@ async fn make_chatwidget_manual( current_status_header: String::from("Working"), retry_status_header: None, conversation_id: None, + forked_from: None, frame_requester: FrameRequester::test_dummy(), show_welcome_banner: true, queued_user_messages: VecDeque::new(), @@ -432,6 +811,19 @@ async fn make_chatwidget_manual( (widget, rx, op_rx) } +// ChatWidget may emit other `Op`s (e.g. history/logging updates) on the same channel; this helper +// filters until we see a submission op. +fn next_submit_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver) -> Op { + loop { + match op_rx.try_recv() { + Ok(op @ Op::UserTurn { .. }) => return op, + Ok(_) => continue, + Err(TryRecvError::Empty) => panic!("expected a submit op but queue was empty"), + Err(TryRecvError::Disconnected) => panic!("expected submit op but channel closed"), + } + } +} + fn set_chatgpt_auth(chat: &mut ChatWidget) { chat.auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); @@ -1013,7 +1405,8 @@ async fn enqueueing_history_prompt_multiple_times_is_stable() { assert!(!chat.bottom_pane.is_task_running()); // Submit an initial prompt to seed history. - chat.bottom_pane.set_composer_text("repeat me".to_string()); + chat.bottom_pane + .set_composer_text("repeat me".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); // Simulate an active task so further submissions are queued. @@ -1049,7 +1442,7 @@ async fn streaming_final_answer_keeps_task_running_state() { assert!(chat.bottom_pane.status_widget().is_none()); chat.bottom_pane - .set_composer_text("queued submission".to_string()); + .set_composer_text("queued submission".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); assert_eq!(chat.queued_user_messages.len(), 1); @@ -1310,6 +1703,112 @@ async fn slash_init_skips_when_project_doc_exists() { ); } +#[tokio::test] +async fn collab_mode_shift_tab_cycles_only_when_enabled_and_idle() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::CollaborationModes, false); + + let initial = chat.stored_collaboration_mode.clone(); + chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); + assert_eq!(chat.stored_collaboration_mode, initial); + + chat.set_feature_enabled(Feature::CollaborationModes, true); + + chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); + assert!(matches!( + chat.stored_collaboration_mode, + CollaborationMode::Execute(_) + )); + + chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); + assert!(matches!( + chat.stored_collaboration_mode, + CollaborationMode::Plan(_) + )); + + chat.on_task_started(); + let before = chat.stored_collaboration_mode.clone(); + chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); + assert_eq!(chat.stored_collaboration_mode, before); +} + +#[tokio::test] +async fn collab_slash_command_opens_picker_and_updates_mode() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.conversation_id = Some(ThreadId::new()); + chat.set_feature_enabled(Feature::CollaborationModes, true); + + chat.dispatch_command(SlashCommand::Collab); + let popup = render_bottom_popup(&chat, 80); + assert!( + popup.contains("Select Collaboration Mode"), + "expected collaboration picker: {popup}" + ); + + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + let selected_mode = match rx.try_recv() { + Ok(AppEvent::UpdateCollaborationMode(mode)) => mode, + other => panic!("expected UpdateCollaborationMode event, got {other:?}"), + }; + chat.set_collaboration_mode(selected_mode); + + chat.bottom_pane + .set_composer_text("hello".to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + match next_submit_op(&mut op_rx) { + Op::UserTurn { + collaboration_mode: Some(CollaborationMode::PairProgramming(_)), + .. + } => {} + other => { + panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}") + } + } + + chat.bottom_pane + .set_composer_text("follow up".to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + match next_submit_op(&mut op_rx) { + Op::UserTurn { + collaboration_mode: Some(CollaborationMode::PairProgramming(_)), + .. + } => {} + other => { + panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}") + } + } +} + +#[tokio::test] +async fn collab_mode_defaults_to_pair_programming_when_enabled() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.conversation_id = Some(ThreadId::new()); + chat.set_feature_enabled(Feature::CollaborationModes, true); + + chat.bottom_pane + .set_composer_text("hello".to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + match next_submit_op(&mut op_rx) { + Op::UserTurn { + collaboration_mode: Some(CollaborationMode::PairProgramming(_)), + .. + } => {} + other => { + panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}") + } + } +} + +#[tokio::test] +async fn collab_mode_enabling_sets_pair_programming_default() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::CollaborationModes, true); + assert!(matches!( + chat.stored_collaboration_mode, + CollaborationMode::PairProgramming(_) + )); +} + #[tokio::test] async fn slash_quit_requests_exit() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -1338,12 +1837,12 @@ async fn slash_resume_opens_picker() { } #[tokio::test] -async fn slash_fork_opens_picker() { +async fn slash_fork_requests_current_fork() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; chat.dispatch_command(SlashCommand::Fork); - assert_matches!(rx.try_recv(), Ok(AppEvent::OpenForkPicker)); + assert_matches!(rx.try_recv(), Ok(AppEvent::ForkCurrentSession)); } #[tokio::test] @@ -1956,7 +2455,7 @@ async fn model_reasoning_selection_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; set_chatgpt_auth(&mut chat); - chat.config.model_reasoning_effort = Some(ReasoningEffortConfig::High); + chat.set_reasoning_effort(Some(ReasoningEffortConfig::High)); let preset = get_available_model(&chat, "gpt-5.1-codex-max"); chat.open_reasoning_popup(preset); @@ -1970,7 +2469,7 @@ async fn model_reasoning_selection_popup_extra_high_warning_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; set_chatgpt_auth(&mut chat); - chat.config.model_reasoning_effort = Some(ReasoningEffortConfig::XHigh); + chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh)); let preset = get_available_model(&chat, "gpt-5.1-codex-max"); chat.open_reasoning_popup(preset); @@ -2374,7 +2873,7 @@ async fn interrupt_prepends_queued_messages_before_existing_composer_text() { chat.bottom_pane.set_task_running(true); chat.bottom_pane - .set_composer_text("current draft".to_string()); + .set_composer_text("current draft".to_string(), Vec::new(), Vec::new()); chat.queued_user_messages .push_back(UserMessage::from("first queued".to_string())); @@ -3356,8 +3855,11 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { delta: "**Investigating rendering code**".into(), }), }); - chat.bottom_pane - .set_composer_text("Summarize recent commits".to_string()); + chat.bottom_pane.set_composer_text( + "Summarize recent commits".to_string(), + Vec::new(), + Vec::new(), + ); let width: u16 = 80; let ui_height: u16 = chat.desired_height(width); diff --git a/codex-rs/tui2/src/clipboard_paste.rs b/codex-rs/tui2/src/clipboard_paste.rs index 5863c728b0..4d28b365fe 100644 --- a/codex-rs/tui2/src/clipboard_paste.rs +++ b/codex-rs/tui2/src/clipboard_paste.rs @@ -244,9 +244,14 @@ pub fn paste_image_to_temp_png() -> Result<(PathBuf, PastedImageInfo), PasteImag /// - shell-escaped single paths (via `shlex`) pub fn normalize_pasted_path(pasted: &str) -> Option { let pasted = pasted.trim(); + let unquoted = pasted + .strip_prefix('"') + .and_then(|s| s.strip_suffix('"')) + .or_else(|| pasted.strip_prefix('\'').and_then(|s| s.strip_suffix('\''))) + .unwrap_or(pasted); // file:// URL → filesystem path - if let Ok(url) = url::Url::parse(pasted) + if let Ok(url) = url::Url::parse(unquoted) && url.scheme() == "file" { return url.to_file_path().ok(); @@ -258,38 +263,18 @@ pub fn normalize_pasted_path(pasted: &str) -> Option { // Detect unquoted Windows paths and bypass POSIX shlex which // treats backslashes as escapes (e.g., C:\Users\Alice\file.png). // Also handles UNC paths (\\server\share\path). - let looks_like_windows_path = { - // Drive letter path: C:\ or C:/ - let drive = pasted - .chars() - .next() - .map(|c| c.is_ascii_alphabetic()) - .unwrap_or(false) - && pasted.get(1..2) == Some(":") - && pasted - .get(2..3) - .map(|s| s == "\\" || s == "/") - .unwrap_or(false); - // UNC path: \\server\share - let unc = pasted.starts_with("\\\\"); - drive || unc - }; - if looks_like_windows_path { - #[cfg(target_os = "linux")] - { - if is_probably_wsl() - && let Some(converted) = convert_windows_path_to_wsl(pasted) - { - return Some(converted); - } - } - return Some(PathBuf::from(pasted)); + if let Some(path) = normalize_windows_path(unquoted) { + return Some(path); } // shell-escaped single path → unescaped let parts: Vec = shlex::Shlex::new(pasted).collect(); if parts.len() == 1 { - return parts.into_iter().next().map(PathBuf::from); + let part = parts.into_iter().next()?; + if let Some(path) = normalize_windows_path(&part) { + return Some(path); + } + return Some(PathBuf::from(part)); } None @@ -339,6 +324,36 @@ fn convert_windows_path_to_wsl(input: &str) -> Option { Some(result) } +fn normalize_windows_path(input: &str) -> Option { + // Drive letter path: C:\ or C:/ + let drive = input + .chars() + .next() + .map(|c| c.is_ascii_alphabetic()) + .unwrap_or(false) + && input.get(1..2) == Some(":") + && input + .get(2..3) + .map(|s| s == "\\" || s == "/") + .unwrap_or(false); + // UNC path: \\server\share + let unc = input.starts_with("\\\\"); + if !drive && !unc { + return None; + } + + #[cfg(target_os = "linux")] + { + if is_probably_wsl() + && let Some(converted) = convert_windows_path_to_wsl(input) + { + return Some(converted); + } + } + + Some(PathBuf::from(input)) +} + /// Infer an image format for the provided path based on its extension. pub fn pasted_image_format(path: &Path) -> EncodedImageFormat { match path @@ -438,9 +453,39 @@ mod pasted_paths_tests { #[test] fn normalize_single_quoted_windows_path() { let input = r"'C:\\Users\\Alice\\My File.jpeg'"; + let unquoted = r"C:\\Users\\Alice\\My File.jpeg"; let result = normalize_pasted_path(input).expect("should trim single quotes on windows path"); - assert_eq!(result, PathBuf::from(r"C:\\Users\\Alice\\My File.jpeg")); + #[cfg(target_os = "linux")] + let expected = if is_probably_wsl() + && let Some(converted) = convert_windows_path_to_wsl(unquoted) + { + converted + } else { + PathBuf::from(unquoted) + }; + #[cfg(not(target_os = "linux"))] + let expected = PathBuf::from(unquoted); + assert_eq!(result, expected); + } + + #[test] + fn normalize_double_quoted_windows_path() { + let input = r#""C:\\Users\\Alice\\My File.jpeg""#; + let unquoted = r"C:\\Users\\Alice\\My File.jpeg"; + let result = + normalize_pasted_path(input).expect("should trim double quotes on windows path"); + #[cfg(target_os = "linux")] + let expected = if is_probably_wsl() + && let Some(converted) = convert_windows_path_to_wsl(unquoted) + { + converted + } else { + PathBuf::from(unquoted) + }; + #[cfg(not(target_os = "linux"))] + let expected = PathBuf::from(unquoted); + assert_eq!(result, expected); } #[test] diff --git a/codex-rs/tui2/src/collab.rs b/codex-rs/tui2/src/collab.rs index 6b1042810c..5a1a18f63c 100644 --- a/codex-rs/tui2/src/collab.rs +++ b/codex-rs/tui2/src/collab.rs @@ -59,12 +59,12 @@ pub(crate) fn waiting_begin(ev: CollabWaitingBeginEvent) -> PlainHistoryCell { let CollabWaitingBeginEvent { call_id, sender_thread_id, - receiver_thread_id, + receiver_thread_ids, } = ev; let details = vec![ detail_line("call", call_id), detail_line("sender", sender_thread_id), - detail_line("receiver", receiver_thread_id), + detail_line("receiver", format!("{receiver_thread_ids:?}")), ]; collab_event("Collab wait begin", details) } @@ -73,14 +73,12 @@ pub(crate) fn waiting_end(ev: CollabWaitingEndEvent) -> PlainHistoryCell { let CollabWaitingEndEvent { call_id, sender_thread_id, - receiver_thread_id, - status, + statuses, } = ev; let details = vec![ detail_line("call", call_id), detail_line("sender", sender_thread_id), - detail_line("receiver", receiver_thread_id), - status_line(&status), + detail_line("statuses", format!("{statuses:#?}")), ]; collab_event("Collab wait end", details) } diff --git a/codex-rs/tui2/src/collaboration_modes.rs b/codex-rs/tui2/src/collaboration_modes.rs new file mode 100644 index 0000000000..f48ad92c91 --- /dev/null +++ b/codex-rs/tui2/src/collaboration_modes.rs @@ -0,0 +1,49 @@ +use codex_core::models_manager::manager::ModelsManager; +use codex_protocol::config_types::CollaborationMode; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ModeKind { + Plan, + PairProgramming, + Execute, + Custom, +} + +fn mode_kind(mode: &CollaborationMode) -> ModeKind { + match mode { + CollaborationMode::Plan(_) => ModeKind::Plan, + CollaborationMode::PairProgramming(_) => ModeKind::PairProgramming, + CollaborationMode::Execute(_) => ModeKind::Execute, + CollaborationMode::Custom(_) => ModeKind::Custom, + } +} + +pub(crate) fn default_mode(models_manager: &ModelsManager) -> Option { + let presets = models_manager.list_collaboration_modes(); + presets + .iter() + .find(|preset| matches!(preset, CollaborationMode::PairProgramming(_))) + .cloned() + .or_else(|| presets.into_iter().next()) +} + +pub(crate) fn same_variant(a: &CollaborationMode, b: &CollaborationMode) -> bool { + mode_kind(a) == mode_kind(b) +} + +/// Cycle to the next collaboration mode preset in list order. +pub(crate) fn next_mode( + models_manager: &ModelsManager, + current: &CollaborationMode, +) -> Option { + let presets = models_manager.list_collaboration_modes(); + if presets.is_empty() { + return None; + } + let current_kind = mode_kind(current); + let next_index = presets + .iter() + .position(|preset| mode_kind(preset) == current_kind) + .map_or(0, |idx| (idx + 1) % presets.len()); + presets.get(next_index).cloned() +} diff --git a/codex-rs/tui2/src/history_cell.rs b/codex-rs/tui2/src/history_cell.rs index e3334a0681..a313f9b2a7 100644 --- a/codex-rs/tui2/src/history_cell.rs +++ b/codex-rs/tui2/src/history_cell.rs @@ -44,6 +44,7 @@ use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; +use codex_protocol::user_input::TextElement; use image::DynamicImage; use image::ImageReader; use mcp_types::EmbeddedResourceResource; @@ -51,6 +52,7 @@ use mcp_types::Resource; use mcp_types::ResourceLink; use mcp_types::ResourceTemplate; use ratatui::prelude::*; +use ratatui::style::Color; use ratatui::style::Modifier; use ratatui::style::Style; use ratatui::style::Styled; @@ -214,6 +216,75 @@ impl dyn HistoryCell { #[derive(Debug)] pub(crate) struct UserHistoryCell { pub message: String, + pub text_elements: Vec, + #[allow(dead_code)] + pub local_image_paths: Vec, +} + +/// Build logical lines for a user message with styled text elements. +/// +/// This preserves explicit newlines while interleaving element spans and skips +/// malformed byte ranges instead of panicking during history rendering. +fn build_user_message_lines_with_elements( + message: &str, + elements: &[TextElement], + style: Style, + element_style: Style, +) -> Vec> { + let mut elements = elements.to_vec(); + elements.sort_by_key(|e| e.byte_range.start); + let mut offset = 0usize; + let mut raw_lines: Vec> = Vec::new(); + for line_text in message.split('\n') { + let line_start = offset; + let line_end = line_start + line_text.len(); + let mut spans: Vec> = Vec::new(); + // Track how much of the line we've emitted to interleave plain and styled spans. + let mut cursor = line_start; + for elem in &elements { + let start = elem.byte_range.start.max(line_start); + let end = elem.byte_range.end.min(line_end); + if start >= end { + continue; + } + let rel_start = start - line_start; + let rel_end = end - line_start; + // Guard against malformed UTF-8 byte ranges from upstream data; skip + // invalid elements rather than panicking while rendering history. + if !line_text.is_char_boundary(rel_start) || !line_text.is_char_boundary(rel_end) { + continue; + } + let rel_cursor = cursor - line_start; + if cursor < start + && line_text.is_char_boundary(rel_cursor) + && let Some(segment) = line_text.get(rel_cursor..rel_start) + { + spans.push(Span::from(segment.to_string())); + } + if let Some(segment) = line_text.get(rel_start..rel_end) { + spans.push(Span::styled(segment.to_string(), element_style)); + cursor = end; + } + } + let rel_cursor = cursor - line_start; + if cursor < line_end + && line_text.is_char_boundary(rel_cursor) + && let Some(segment) = line_text.get(rel_cursor..) + { + spans.push(Span::from(segment.to_string())); + } + let line = if spans.is_empty() { + Line::from(line_text.to_string()).style(style) + } else { + Line::from(spans).style(style) + }; + raw_lines.push(line); + // Split on '\n' so any '\r' stays in the line; advancing by 1 accounts + // for the separator byte. + offset = line_end + 1; + } + + raw_lines } impl HistoryCell for UserHistoryCell { @@ -229,13 +300,28 @@ impl HistoryCell for UserHistoryCell { .max(1); let style = user_message_style(); + let element_style = style.fg(Color::Cyan); - let (wrapped, joiner_before) = crate::wrapping::word_wrap_lines_with_joiners( - self.message.lines().map(|l| Line::from(l).style(style)), - // Wrap algorithm matches textarea.rs. - RtOptions::new(usize::from(wrap_width)) - .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), - ); + let (wrapped, joiner_before) = if self.text_elements.is_empty() { + crate::wrapping::word_wrap_lines_with_joiners( + self.message.split('\n').map(|l| Line::from(l).style(style)), + // Wrap algorithm matches textarea.rs. + RtOptions::new(usize::from(wrap_width)) + .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), + ) + } else { + let raw_lines = build_user_message_lines_with_elements( + &self.message, + &self.text_elements, + style, + element_style, + ); + crate::wrapping::word_wrap_lines_with_joiners( + raw_lines, + RtOptions::new(usize::from(wrap_width)) + .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), + ) + }; let mut lines: Vec> = Vec::new(); let mut joins: Vec> = Vec::new(); @@ -955,8 +1041,16 @@ pub(crate) fn new_session_info( SessionInfoCell(CompositeHistoryCell { parts }) } -pub(crate) fn new_user_prompt(message: String) -> UserHistoryCell { - UserHistoryCell { message } +pub(crate) fn new_user_prompt( + message: String, + text_elements: Vec, + local_image_paths: Vec, +) -> UserHistoryCell { + UserHistoryCell { + message, + text_elements, + local_image_paths, + } } #[derive(Debug)] @@ -1395,7 +1489,8 @@ pub(crate) fn empty_mcp_output() -> PlainHistoryCell { " • No MCP servers configured.".italic().into(), Line::from(vec![ " See the ".into(), - "\u{1b}]8;;https://github.com/openai/codex/blob/main/docs/config.md#mcp_servers\u{7}MCP docs\u{1b}]8;;\u{7}".underlined(), + "\u{1b}]8;;https://developers.openai.com/codex/mcp\u{7}MCP docs\u{1b}]8;;\u{7}" + .underlined(), " to configure them.".into(), ]) .style(Style::default().add_modifier(Modifier::DIM)), @@ -2717,6 +2812,8 @@ mod tests { let msg = "one two three four five six seven"; let cell = UserHistoryCell { message: msg.to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), }; // Small width to force wrapping more clearly. Effective wrap width is width-2 due to the ▌ prefix and trailing space. diff --git a/codex-rs/tui2/src/lib.rs b/codex-rs/tui2/src/lib.rs index 55e640c58f..bc224610b3 100644 --- a/codex-rs/tui2/src/lib.rs +++ b/codex-rs/tui2/src/lib.rs @@ -15,8 +15,10 @@ use codex_core::AuthManager; use codex_core::CodexAuth; use codex_core::INTERACTIVE_SESSION_SOURCES; use codex_core::RolloutRecorder; +use codex_core::ThreadSortKey; use codex_core::auth::enforce_login_restrictions; use codex_core::config::Config; +use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; use codex_core::config::find_codex_home; use codex_core::config::load_config_as_toml_with_cli_overrides; @@ -24,6 +26,7 @@ use codex_core::config::resolve_oss_provider; use codex_core::find_thread_path_by_id_str; use codex_core::get_platform_sandbox; use codex_core::protocol::AskForApproval; +use codex_core::read_session_meta_line; use codex_core::terminal::Multiplexer; use codex_protocol::config_types::AltScreenMode; use codex_protocol::config_types::SandboxMode; @@ -47,6 +50,7 @@ mod cli; mod clipboard_copy; mod clipboard_paste; mod collab; +mod collaboration_modes; mod color; pub mod custom_terminal; mod diff_render; @@ -260,7 +264,6 @@ pub async fn run_main( std::process::exit(1); } - let active_profile = config.active_profile.clone(); let log_dir = codex_core::config::log_dir(&config)?; std::fs::create_dir_all(&log_dir)?; // Open (or create) your log file, appending to it. @@ -353,16 +356,9 @@ pub async fn run_main( let terminal_info = codex_core::terminal::terminal_info(); tracing::info!(terminal = ?terminal_info, "Detected terminal info"); - run_ratatui_app( - cli, - config, - overrides, - cli_kv_overrides, - active_profile, - feedback, - ) - .await - .map_err(|err| std::io::Error::other(err.to_string())) + run_ratatui_app(cli, config, overrides, cli_kv_overrides, feedback) + .await + .map_err(|err| std::io::Error::other(err.to_string())) } async fn run_ratatui_app( @@ -370,7 +366,6 @@ async fn run_ratatui_app( initial_config: Config, overrides: ConfigOverrides, cli_kv_overrides: Vec<(String, toml::Value)>, - active_profile: Option, feedback: codex_feedback::CodexFeedback, ) -> color_eyre::Result { color_eyre::install()?; @@ -422,15 +417,15 @@ async fn run_ratatui_app( initial_config.cli_auth_credentials_store_mode, ); let login_status = get_login_status(&initial_config); - let should_show_trust_screen = should_show_trust_screen(&initial_config); + let should_show_trust_screen_flag = should_show_trust_screen(&initial_config); let should_show_onboarding = - should_show_onboarding(login_status, &initial_config, should_show_trust_screen); + should_show_onboarding(login_status, &initial_config, should_show_trust_screen_flag); let config = if should_show_onboarding { let onboarding_result = run_onboarding_app( OnboardingScreenArgs { show_login_screen: should_show_login_screen(login_status, &initial_config), - show_trust_screen: should_show_trust_screen, + show_trust_screen: should_show_trust_screen_flag, login_status, auth_manager: auth_manager.clone(), config: initial_config.clone(), @@ -456,7 +451,7 @@ async fn run_ratatui_app( .map(|d| d == TrustDirectorySelection::Trust) .unwrap_or(false) { - load_config_or_exit(cli_kv_overrides, overrides).await + load_config_or_exit(cli_kv_overrides.clone(), overrides.clone()).await } else { initial_config } @@ -500,6 +495,7 @@ async fn run_ratatui_app( &config.codex_home, 1, None, + ThreadSortKey::UpdatedAt, INTERACTIVE_SESSION_SOURCES, Some(provider_filter.as_slice()), &config.model_provider_id, @@ -552,6 +548,9 @@ async fn run_ratatui_app( }; match RolloutRecorder::find_latest_thread_path( &config.codex_home, + 1, + None, + ThreadSortKey::UpdatedAt, INTERACTIVE_SESSION_SOURCES, Some(provider_filter.as_slice()), &config.model_provider_id, @@ -588,6 +587,33 @@ async fn run_ratatui_app( resume_picker::SessionSelection::StartFresh }; + let config = match &session_selection { + resume_picker::SessionSelection::Resume(path) + | resume_picker::SessionSelection::Fork(path) => { + let history_cwd = match read_session_meta_line(path).await { + Ok(meta_line) => Some(meta_line.meta.cwd), + Err(err) => { + let rollout_path = path.display().to_string(); + tracing::warn!( + %rollout_path, + %err, + "Failed to read session metadata from rollout" + ); + None + } + }; + load_config_or_exit_with_fallback_cwd( + cli_kv_overrides.clone(), + overrides.clone(), + history_cwd, + ) + .await + } + _ => config, + }; + let active_profile = config.active_profile.clone(); + let should_show_trust_screen = should_show_trust_screen(&config); + let Cli { prompt, images, @@ -693,9 +719,23 @@ fn get_login_status(config: &Config) -> LoginStatus { async fn load_config_or_exit( cli_kv_overrides: Vec<(String, toml::Value)>, overrides: ConfigOverrides, +) -> Config { + load_config_or_exit_with_fallback_cwd(cli_kv_overrides, overrides, None).await +} + +async fn load_config_or_exit_with_fallback_cwd( + cli_kv_overrides: Vec<(String, toml::Value)>, + overrides: ConfigOverrides, + fallback_cwd: Option, ) -> Config { #[allow(clippy::print_stderr)] - match Config::load_with_cli_overrides_and_harness_overrides(cli_kv_overrides, overrides).await { + match ConfigBuilder::default() + .cli_overrides(cli_kv_overrides) + .harness_overrides(overrides) + .fallback_cwd(fallback_cwd) + .build() + .await + { Ok(config) => config, Err(err) => { eprintln!("Error loading configuration: {err}"); diff --git a/codex-rs/tui2/src/onboarding/auth.rs b/codex-rs/tui2/src/onboarding/auth.rs index 640fca14da..a1fb3dee12 100644 --- a/codex-rs/tui2/src/onboarding/auth.rs +++ b/codex-rs/tui2/src/onboarding/auth.rs @@ -5,7 +5,6 @@ use codex_core::auth::AuthCredentialsStoreMode; use codex_core::auth::CLIENT_ID; use codex_core::auth::login_with_api_key; use codex_core::auth::read_openai_api_key_from_env; -use codex_core::env::is_headless_environment; use codex_login::DeviceCode; use codex_login::ServerOptions; use codex_login::ShutdownHandle; @@ -59,6 +58,13 @@ pub(crate) enum SignInState { ApiKeyConfigured, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum SignInOption { + ChatGpt, + DeviceCode, + ApiKey, +} + const API_KEY_DISABLED_MESSAGE: &str = "API key login is disabled."; #[derive(Clone, Default)] @@ -96,42 +102,26 @@ impl KeyboardHandler for AuthModeWidget { match key_event.code { KeyCode::Up | KeyCode::Char('k') => { - if self.is_chatgpt_login_allowed() { - self.highlighted_mode = AuthMode::ChatGPT; - } + self.move_highlight(-1); } KeyCode::Down | KeyCode::Char('j') => { - if self.is_api_login_allowed() { - self.highlighted_mode = AuthMode::ApiKey; - } + self.move_highlight(1); } KeyCode::Char('1') => { - if self.is_chatgpt_login_allowed() { - self.start_chatgpt_login(); - } + self.select_option_by_index(0); } KeyCode::Char('2') => { - if self.is_api_login_allowed() { - self.start_api_key_entry(); - } else { - self.disallow_api_login(); - } + self.select_option_by_index(1); + } + KeyCode::Char('3') => { + self.select_option_by_index(2); } KeyCode::Enter => { let sign_in_state = { (*self.sign_in_state.read().unwrap()).clone() }; match sign_in_state { - SignInState::PickMode => match self.highlighted_mode { - AuthMode::ChatGPT if self.is_chatgpt_login_allowed() => { - self.start_chatgpt_login(); - } - AuthMode::ApiKey if self.is_api_login_allowed() => { - self.start_api_key_entry(); - } - AuthMode::ChatGPT => {} - AuthMode::ApiKey => { - self.disallow_api_login(); - } - }, + SignInState::PickMode => { + self.handle_sign_in_option(self.highlighted_mode); + } SignInState::ChatGptSuccessMessage => { *self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess; } @@ -170,7 +160,7 @@ impl KeyboardHandler for AuthModeWidget { #[derive(Clone)] pub(crate) struct AuthModeWidget { pub request_frame: FrameRequester, - pub highlighted_mode: AuthMode, + pub highlighted_mode: SignInOption, pub error: Option, pub sign_in_state: Arc>, pub codex_home: PathBuf, @@ -191,8 +181,75 @@ impl AuthModeWidget { !matches!(self.forced_login_method, Some(ForcedLoginMethod::Api)) } + fn displayed_sign_in_options(&self) -> Vec { + let mut options = vec![SignInOption::ChatGpt]; + if self.is_chatgpt_login_allowed() { + options.push(SignInOption::DeviceCode); + } + if self.is_api_login_allowed() { + options.push(SignInOption::ApiKey); + } + options + } + + fn selectable_sign_in_options(&self) -> Vec { + let mut options = Vec::new(); + if self.is_chatgpt_login_allowed() { + options.push(SignInOption::ChatGpt); + options.push(SignInOption::DeviceCode); + } + if self.is_api_login_allowed() { + options.push(SignInOption::ApiKey); + } + options + } + + fn move_highlight(&mut self, delta: isize) { + let options = self.selectable_sign_in_options(); + if options.is_empty() { + return; + } + + let current_index = options + .iter() + .position(|option| *option == self.highlighted_mode) + .unwrap_or(0); + let next_index = + (current_index as isize + delta).rem_euclid(options.len() as isize) as usize; + self.highlighted_mode = options[next_index]; + } + + fn select_option_by_index(&mut self, index: usize) { + let options = self.displayed_sign_in_options(); + if let Some(option) = options.get(index).copied() { + self.handle_sign_in_option(option); + } + } + + fn handle_sign_in_option(&mut self, option: SignInOption) { + match option { + SignInOption::ChatGpt => { + if self.is_chatgpt_login_allowed() { + self.start_chatgpt_login(); + } + } + SignInOption::DeviceCode => { + if self.is_chatgpt_login_allowed() { + self.start_device_code_login(); + } + } + SignInOption::ApiKey => { + if self.is_api_login_allowed() { + self.start_api_key_entry(); + } else { + self.disallow_api_login(); + } + } + } + } + fn disallow_api_login(&mut self) { - self.highlighted_mode = AuthMode::ChatGPT; + self.highlighted_mode = SignInOption::ChatGpt; self.error = Some(API_KEY_DISABLED_MESSAGE.to_string()); *self.sign_in_state.write().unwrap() = SignInState::PickMode; self.request_frame.schedule_frame(); @@ -212,7 +269,7 @@ impl AuthModeWidget { ]; let create_mode_item = |idx: usize, - selected_mode: AuthMode, + selected_mode: SignInOption, text: &str, description: &str| -> Vec> { @@ -221,11 +278,11 @@ impl AuthModeWidget { let line1 = if is_selected { Line::from(vec![ - format!("{} {}. ", caret, idx + 1).cyan().dim(), + format!("{caret} {index}. ", index = idx + 1).cyan().dim(), text.to_string().cyan(), ]) } else { - format!(" {}. {text}", idx + 1).into() + format!(" {index}. {text}", index = idx + 1).into() }; let line2 = if is_selected { @@ -242,27 +299,42 @@ impl AuthModeWidget { let chatgpt_description = if !self.is_chatgpt_login_allowed() { "ChatGPT login is disabled" - } else if is_headless_environment() { - "Uses device code login (headless environment detected)" } else { "Usage included with Plus, Pro, Team, and Enterprise plans" }; - lines.extend(create_mode_item( - 0, - AuthMode::ChatGPT, - "Sign in with ChatGPT", - chatgpt_description, - )); - lines.push("".into()); - if self.is_api_login_allowed() { - lines.extend(create_mode_item( - 1, - AuthMode::ApiKey, - "Provide your own API key", - "Pay for what you use", - )); + let device_code_description = "Sign in from another device with a one-time code"; + + for (idx, option) in self.displayed_sign_in_options().into_iter().enumerate() { + match option { + SignInOption::ChatGpt => { + lines.extend(create_mode_item( + idx, + option, + "Sign in with ChatGPT", + chatgpt_description, + )); + } + SignInOption::DeviceCode => { + lines.extend(create_mode_item( + idx, + option, + "Sign in with Device Code", + device_code_description, + )); + } + SignInOption::ApiKey => { + lines.extend(create_mode_item( + idx, + option, + "Provide your own API key", + "Pay for what you use", + )); + } + } lines.push("".into()); - } else { + } + + if !self.is_api_login_allowed() { lines.push( " API key login is disabled by this workspace. Sign in with ChatGPT to continue." .dim() @@ -309,9 +381,9 @@ impl AuthModeWidget { ])); lines.push("".into()); lines.push(Line::from(vec![ - " On a remote or headless machine? Use ".into(), - "codex login --device-auth".cyan(), - " instead".into(), + " On a remote or headless machine? Press Esc and choose ".into(), + "Sign in with Device Code".cyan(), + ".".into(), ])); lines.push("".into()); } @@ -588,12 +660,20 @@ impl AuthModeWidget { self.request_frame.schedule_frame(); } - fn start_chatgpt_login(&mut self) { - // If we're already authenticated with ChatGPT, don't start a new login – - // just proceed to the success message flow. + fn handle_existing_chatgpt_login(&mut self) -> bool { if matches!(self.login_status, LoginStatus::AuthMode(AuthMode::ChatGPT)) { *self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess; self.request_frame.schedule_frame(); + true + } else { + false + } + } + + fn start_chatgpt_login(&mut self) { + // If we're already authenticated with ChatGPT, don't start a new login – + // just proceed to the success message flow. + if self.handle_existing_chatgpt_login() { return; } @@ -605,11 +685,6 @@ impl AuthModeWidget { self.cli_auth_credentials_store_mode, ); - if is_headless_environment() { - headless_chatgpt_login::start_headless_chatgpt_login(self, opts); - return; - } - match run_login_server(opts) { Ok(child) => { let sign_in_state = self.sign_in_state.clone(); @@ -649,6 +724,21 @@ impl AuthModeWidget { } } } + + fn start_device_code_login(&mut self) { + if self.handle_existing_chatgpt_login() { + return; + } + + self.error = None; + let opts = ServerOptions::new( + self.codex_home.clone(), + CLIENT_ID.to_string(), + self.forced_chatgpt_workspace_id.clone(), + self.cli_auth_credentials_store_mode, + ); + headless_chatgpt_login::start_headless_chatgpt_login(self, opts); + } } impl StepStateProvider for AuthModeWidget { @@ -707,7 +797,7 @@ mod tests { let codex_home_path = codex_home.path().to_path_buf(); let widget = AuthModeWidget { request_frame: FrameRequester::test_dummy(), - highlighted_mode: AuthMode::ChatGPT, + highlighted_mode: SignInOption::ChatGpt, error: None, sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)), codex_home: codex_home_path.clone(), diff --git a/codex-rs/tui2/src/onboarding/onboarding_screen.rs b/codex-rs/tui2/src/onboarding/onboarding_screen.rs index 3ba2619a87..b16a980625 100644 --- a/codex-rs/tui2/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui2/src/onboarding/onboarding_screen.rs @@ -11,11 +11,11 @@ use ratatui::style::Color; use ratatui::widgets::Clear; use ratatui::widgets::WidgetRef; -use codex_app_server_protocol::AuthMode; use codex_protocol::config_types::ForcedLoginMethod; use crate::LoginStatus; use crate::onboarding::auth::AuthModeWidget; +use crate::onboarding::auth::SignInOption; use crate::onboarding::auth::SignInState; use crate::onboarding::trust_directory::TrustDirectorySelection; use crate::onboarding::trust_directory::TrustDirectoryWidget; @@ -92,8 +92,8 @@ impl OnboardingScreen { ))); if show_login_screen { let highlighted_mode = match forced_login_method { - Some(ForcedLoginMethod::Api) => AuthMode::ApiKey, - _ => AuthMode::ChatGPT, + Some(ForcedLoginMethod::Api) => SignInOption::ApiKey, + _ => SignInOption::ChatGpt, }; steps.push(Step::Auth(AuthModeWidget { request_frame: tui.frame_requester(), diff --git a/codex-rs/tui2/src/pager_overlay.rs b/codex-rs/tui2/src/pager_overlay.rs index 6fb6d123cb..c981ae951d 100644 --- a/codex-rs/tui2/src/pager_overlay.rs +++ b/codex-rs/tui2/src/pager_overlay.rs @@ -93,6 +93,8 @@ const KEY_SPACE: KeyBinding = key_hint::plain(KeyCode::Char(' ')); const KEY_SHIFT_SPACE: KeyBinding = key_hint::shift(KeyCode::Char(' ')); const KEY_HOME: KeyBinding = key_hint::plain(KeyCode::Home); const KEY_END: KeyBinding = key_hint::plain(KeyCode::End); +const KEY_LEFT: KeyBinding = key_hint::plain(KeyCode::Left); +const KEY_RIGHT: KeyBinding = key_hint::plain(KeyCode::Right); const KEY_CTRL_F: KeyBinding = key_hint::ctrl(KeyCode::Char('f')); const KEY_CTRL_D: KeyBinding = key_hint::ctrl(KeyCode::Char('d')); const KEY_CTRL_B: KeyBinding = key_hint::ctrl(KeyCode::Char('b')); @@ -656,10 +658,13 @@ impl TranscriptOverlay { let line2 = Rect::new(area.x, area.y.saturating_add(1), area.width, 1); render_key_hints(line1, buf, PAGER_KEY_HINTS); - let mut pairs: Vec<(&[KeyBinding], &str)> = - vec![(&[KEY_Q], "to quit"), (&[KEY_ESC], "to edit prev")]; + let mut pairs: Vec<(&[KeyBinding], &str)> = vec![(&[KEY_Q], "to quit")]; if self.highlight_cell.is_some() { + pairs.push((&[KEY_ESC, KEY_LEFT], "to edit prev")); + pairs.push((&[KEY_RIGHT], "to edit next")); pairs.push((&[KEY_ENTER], "to edit message")); + } else { + pairs.push((&[KEY_ESC], "to edit prev")); } render_key_hints(line2, buf, &pairs); } @@ -837,25 +842,37 @@ mod tests { lines: vec![Line::from("hello")], })]); - // Render into a small buffer and assert the backtrack hint is present - let area = Rect::new(0, 0, 40, 10); + // Render into a wide buffer so the footer hints aren't truncated. + let area = Rect::new(0, 0, 120, 10); let mut buf = Buffer::empty(area); overlay.render(area, &mut buf); - // Flatten buffer to a string and check for the hint text - let mut s = String::new(); - for y in area.y..area.bottom() { - for x in area.x..area.right() { - s.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); - } - s.push('\n'); - } + let s = buffer_to_text(&buf, area); assert!( s.contains("edit prev"), "expected 'edit prev' hint in overlay footer, got: {s:?}" ); } + #[test] + fn edit_next_hint_is_visible_when_highlighted() { + let mut overlay = TranscriptOverlay::new(vec![Arc::new(TestCell { + lines: vec![Line::from("hello")], + })]); + overlay.set_highlight_cell(Some(0)); + + // Render into a wide buffer so the footer hints aren't truncated. + let area = Rect::new(0, 0, 120, 10); + let mut buf = Buffer::empty(area); + overlay.render(area, &mut buf); + + let s = buffer_to_text(&buf, area); + assert!( + s.contains("edit next"), + "expected 'edit next' hint in overlay footer, got: {s:?}" + ); + } + #[test] fn transcript_overlay_snapshot_basic() { // Prepare a transcript overlay with a few lines diff --git a/codex-rs/tui2/src/public_widgets/composer_input.rs b/codex-rs/tui2/src/public_widgets/composer_input.rs index 2a80c087ee..46a7e72bcf 100644 --- a/codex-rs/tui2/src/public_widgets/composer_input.rs +++ b/codex-rs/tui2/src/public_widgets/composer_input.rs @@ -48,13 +48,14 @@ impl ComposerInput { /// Clear the input text. pub fn clear(&mut self) { - self.inner.set_text_content(String::new()); + self.inner + .set_text_content(String::new(), Vec::new(), Vec::new()); } /// Feed a key event into the composer and return a high-level action. pub fn input(&mut self, key: KeyEvent) -> ComposerAction { let action = match self.inner.handle_key_event(key).0 { - InputResult::Submitted(text) => ComposerAction::Submitted(text), + InputResult::Submitted { text, .. } => ComposerAction::Submitted(text), _ => ComposerAction::None, }; self.drain_app_events(); diff --git a/codex-rs/tui2/src/render/renderable.rs b/codex-rs/tui2/src/render/renderable.rs index 8581468c0a..eb514b3b0f 100644 --- a/codex-rs/tui2/src/render/renderable.rs +++ b/codex-rs/tui2/src/render/renderable.rs @@ -275,7 +275,6 @@ impl<'a> FlexRenderable<'a> { }; let child_size = child.desired_height(area.width).min(max_child_extent); child_sizes[i] = child_size; - allocated_size += child_size; allocated_flex_space += child_size; } } diff --git a/codex-rs/tui2/src/resume_picker.rs b/codex-rs/tui2/src/resume_picker.rs index 5e96c8f2bb..4ce17721cd 100644 --- a/codex-rs/tui2/src/resume_picker.rs +++ b/codex-rs/tui2/src/resume_picker.rs @@ -9,6 +9,7 @@ use codex_core::Cursor; use codex_core::INTERACTIVE_SESSION_SOURCES; use codex_core::RolloutRecorder; use codex_core::ThreadItem; +use codex_core::ThreadSortKey; use codex_core::ThreadsPage; use codex_core::path_utils; use codex_protocol::items::TurnItem; @@ -156,6 +157,7 @@ async fn run_session_picker( &request.codex_home, PAGE_SIZE, request.cursor.as_ref(), + ThreadSortKey::CreatedAt, INTERACTIVE_SESSION_SOURCES, Some(provider_filter.as_slice()), request.default_provider.as_str(), @@ -1360,7 +1362,6 @@ mod tests { "cwd": cwd, "originator": "user", "cli_version": "0.0.0", - "instructions": null, "source": "Cli", "model_provider": "openai", } @@ -1422,6 +1423,7 @@ mod tests { &state.codex_home, PAGE_SIZE, None, + ThreadSortKey::CreatedAt, INTERACTIVE_SESSION_SOURCES, Some(&[String::from("openai")]), "openai", diff --git a/codex-rs/tui2/src/slash_command.rs b/codex-rs/tui2/src/slash_command.rs index e2d7761221..2e1e032983 100644 --- a/codex-rs/tui2/src/slash_command.rs +++ b/codex-rs/tui2/src/slash_command.rs @@ -23,6 +23,7 @@ pub enum SlashCommand { Fork, Init, Compact, + Collab, // Undo, Diff, Mention, @@ -46,7 +47,7 @@ impl SlashCommand { SlashCommand::Compact => "summarize conversation to prevent hitting the context limit", SlashCommand::Review => "review my current changes and find issues", SlashCommand::Resume => "resume a saved chat", - SlashCommand::Fork => "fork a saved chat", + SlashCommand::Fork => "fork the current chat", // SlashCommand::Undo => "ask Codex to undo a turn", SlashCommand::Quit | SlashCommand::Exit => "exit Codex", SlashCommand::Diff => "show git diff (including untracked files)", @@ -54,6 +55,7 @@ impl SlashCommand { SlashCommand::Skills => "use skills to improve how Codex performs specific tasks", SlashCommand::Status => "show current session configuration and token usage", SlashCommand::Model => "choose what model and reasoning effort to use", + SlashCommand::Collab => "change collaboration mode (experimental)", SlashCommand::Approvals => "choose what Codex can do without approval", SlashCommand::ElevateSandbox => "set up elevated agent sandbox", SlashCommand::Mcp => "list configured MCP tools", @@ -93,6 +95,7 @@ impl SlashCommand { | SlashCommand::Exit => true, SlashCommand::Rollout => true, SlashCommand::TestApproval => true, + SlashCommand::Collab => true, } } diff --git a/codex-rs/tui2/src/status/card.rs b/codex-rs/tui2/src/status/card.rs index 55896365e2..1de70f1454 100644 --- a/codex-rs/tui2/src/status/card.rs +++ b/codex-rs/tui2/src/status/card.rs @@ -5,7 +5,8 @@ use crate::history_cell::with_border_with_inner_width; use crate::version::CODEX_CLI_VERSION; use chrono::DateTime; use chrono::Local; -use codex_common::create_config_summary_entries; +use codex_common::summarize_sandbox_policy; +use codex_core::WireApi; use codex_core::config::Config; use codex_core::protocol::NetworkAccess; use codex_core::protocol::SandboxPolicy; @@ -13,6 +14,7 @@ use codex_core::protocol::TokenUsage; use codex_core::protocol::TokenUsageInfo; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; +use codex_protocol::openai_models::ReasoningEffort; use ratatui::prelude::*; use ratatui::style::Stylize; use std::collections::BTreeSet; @@ -63,9 +65,11 @@ struct StatusHistoryCell { approval: String, sandbox: String, agents_summary: String, + collaboration_mode: Option, model_provider: Option, account: Option, session_id: Option, + forked_from: Option, token_usage: StatusTokenUsageData, rate_limits: StatusRateLimitData, } @@ -77,10 +81,13 @@ pub(crate) fn new_status_output( token_info: Option<&TokenUsageInfo>, total_usage: &TokenUsage, session_id: &Option, + forked_from: Option, rate_limits: Option<&RateLimitSnapshotDisplay>, plan_type: Option, now: DateTime, model_name: &str, + collaboration_mode: Option<&str>, + reasoning_effort_override: Option>, ) -> CompositeHistoryCell { let command = PlainHistoryCell::new(vec!["/status".magenta().into()]); let card = StatusHistoryCell::new( @@ -89,10 +96,13 @@ pub(crate) fn new_status_output( token_info, total_usage, session_id, + forked_from, rate_limits, plan_type, now, model_name, + collaboration_mode, + reasoning_effort_override, ); CompositeHistoryCell::new(vec![Box::new(command), Box::new(card)]) @@ -106,12 +116,35 @@ impl StatusHistoryCell { token_info: Option<&TokenUsageInfo>, total_usage: &TokenUsage, session_id: &Option, + forked_from: Option, rate_limits: Option<&RateLimitSnapshotDisplay>, plan_type: Option, now: DateTime, model_name: &str, + collaboration_mode: Option<&str>, + reasoning_effort_override: Option>, ) -> Self { - let config_entries = create_config_summary_entries(config, model_name); + let mut config_entries = vec![ + ("workdir", config.cwd.display().to_string()), + ("model", model_name.to_string()), + ("provider", config.model_provider_id.clone()), + ("approval", config.approval_policy.value().to_string()), + ( + "sandbox", + summarize_sandbox_policy(config.sandbox_policy.get()), + ), + ]; + if config.model_provider.wire_api == WireApi::Responses { + let effort_value = reasoning_effort_override + .unwrap_or(None) + .map(|effort| effort.to_string()) + .unwrap_or_else(|| "none".to_string()); + config_entries.push(("reasoning effort", effort_value)); + config_entries.push(( + "reasoning summaries", + config.model_reasoning_summary.to_string(), + )); + } let (model_name, model_details) = compose_model_display(model_name, &config_entries); let approval = config_entries .iter() @@ -134,6 +167,7 @@ impl StatusHistoryCell { let model_provider = format_model_provider(config); let account = compose_account_display(auth_manager, plan_type); let session_id = session_id.as_ref().map(std::string::ToString::to_string); + let forked_from = forked_from.map(|id| id.to_string()); let default_usage = TokenUsage::default(); let (context_usage, context_window) = match token_info { Some(info) => (&info.last_token_usage, info.model_context_window), @@ -160,9 +194,11 @@ impl StatusHistoryCell { approval, sandbox, agents_summary, + collaboration_mode: collaboration_mode.map(ToString::to_string), model_provider, account, session_id, + forked_from, token_usage, rate_limits, } @@ -351,6 +387,12 @@ impl HistoryCell for StatusHistoryCell { if self.session_id.is_some() { push_label(&mut labels, &mut seen, "Session"); } + if self.session_id.is_some() && self.forked_from.is_some() { + push_label(&mut labels, &mut seen, "Forked from"); + } + if self.collaboration_mode.is_some() { + push_label(&mut labels, &mut seen, "Collaboration mode"); + } push_label(&mut labels, &mut seen, "Token usage"); if self.token_usage.context_window.is_some() { push_label(&mut labels, &mut seen, "Context window"); @@ -399,9 +441,18 @@ impl HistoryCell for StatusHistoryCell { lines.push(formatter.line("Account", vec![Span::from(account_value)])); } + if let Some(collab_mode) = self.collaboration_mode.as_ref() { + lines.push(formatter.line("Collaboration mode", vec![Span::from(collab_mode.clone())])); + } + if let Some(session) = self.session_id.as_ref() { lines.push(formatter.line("Session", vec![Span::from(session.clone())])); } + if self.session_id.is_some() + && let Some(forked_from) = self.forked_from.as_ref() + { + lines.push(formatter.line("Forked from", vec![Span::from(forked_from.clone())])); + } lines.push(Line::from(Vec::>::new())); // Hide token usage only for ChatGPT subscribers diff --git a/codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_includes_forked_from.snap b/codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_includes_forked_from.snap new file mode 100644 index 0000000000..f238fd0eeb --- /dev/null +++ b/codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_includes_forked_from.snap @@ -0,0 +1,24 @@ +--- +source: tui2/src/status/tests.rs +expression: sanitized +--- +/status + +╭───────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Approval: on-request │ +│ Sandbox: read-only │ +│ Agents.md: │ +│ Session: 0f0f3c13-6cf9-4aa4-8b80-7d49c2f1be2e │ +│ Forked from: e9f18a88-8081-4e51-9d4e-8af5cde2d8dd │ +│ │ +│ Token usage: 1.2K total (800 input + 400 output) │ +│ Context window: 100% left (1.2K used / 272K) │ +│ Limits: data not available yet │ +╰───────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui2/src/status/tests.rs b/codex-rs/tui2/src/status/tests.rs index b16940a4de..8f24af59d6 100644 --- a/codex-rs/tui2/src/status/tests.rs +++ b/codex-rs/tui2/src/status/tests.rs @@ -14,6 +14,7 @@ use codex_core::protocol::RateLimitWindow; use codex_core::protocol::SandboxPolicy; use codex_core::protocol::TokenUsage; use codex_core::protocol::TokenUsageInfo; +use codex_protocol::ThreadId; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::openai_models::ReasoningEffort; use insta::assert_snapshot; @@ -94,7 +95,6 @@ async fn status_snapshot_includes_reasoning_details() { let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); config.model_provider_id = "openai".to_string(); - config.model_reasoning_effort = Some(ReasoningEffort::High); config.model_reasoning_summary = ReasoningSummary::Detailed; config .sandbox_policy @@ -140,16 +140,20 @@ async fn status_snapshot_includes_reasoning_details() { let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); let token_info = token_info_for(&model_slug, &config, &usage); + let reasoning_effort_override = Some(Some(ReasoningEffort::High)); let composite = new_status_output( &config, &auth_manager, Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, &model_slug, + None, + reasoning_effort_override, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -160,6 +164,60 @@ async fn status_snapshot_includes_reasoning_details() { let sanitized = sanitize_directory(rendered_lines).join("\n"); assert_snapshot!(sanitized); } + +#[tokio::test] +async fn status_snapshot_includes_forked_from() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config.model = Some("gpt-5.1-codex-max".to_string()); + config.model_provider_id = "openai".to_string(); + config.cwd = PathBuf::from("/workspace/tests"); + + let auth_manager = test_auth_manager(&config); + let usage = TokenUsage { + input_tokens: 800, + cached_input_tokens: 0, + output_tokens: 400, + reasoning_output_tokens: 0, + total_tokens: 1_200, + }; + + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 8, 9, 10, 11, 12) + .single() + .expect("valid time"); + + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let token_info = token_info_for(&model_slug, &config, &usage); + let session_id = + ThreadId::from_string("0f0f3c13-6cf9-4aa4-8b80-7d49c2f1be2e").expect("session id"); + let forked_from = + ThreadId::from_string("e9f18a88-8081-4e51-9d4e-8af5cde2d8dd").expect("forked id"); + + let composite = new_status_output( + &config, + &auth_manager, + Some(&token_info), + &usage, + &Some(session_id), + Some(forked_from), + None, + None, + captured_at, + &model_slug, + None, + None, + ); + let mut rendered_lines = render_lines(&composite.display_lines(80)); + if cfg!(windows) { + for line in &mut rendered_lines { + *line = line.replace('\\', "/"); + } + } + let sanitized = sanitize_directory(rendered_lines).join("\n"); + assert_snapshot!(sanitized); +} + #[tokio::test] async fn status_snapshot_includes_monthly_limit() { let temp_home = TempDir::new().expect("temp home"); @@ -201,10 +259,13 @@ async fn status_snapshot_includes_monthly_limit() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, &model_slug, + None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -244,10 +305,13 @@ async fn status_snapshot_shows_unlimited_credits() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, &model_slug, + None, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -287,10 +351,13 @@ async fn status_snapshot_shows_positive_credits() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, &model_slug, + None, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -330,10 +397,13 @@ async fn status_snapshot_hides_zero_credits() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, &model_slug, + None, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -371,10 +441,13 @@ async fn status_snapshot_hides_when_has_no_credits_flag() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, &model_slug, + None, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -414,8 +487,11 @@ async fn status_card_token_usage_excludes_cached_tokens() { &None, None, None, + None, now, &model_slug, + None, + None, ); let rendered = render_lines(&composite.display_lines(120)); @@ -431,7 +507,6 @@ async fn status_snapshot_truncates_in_narrow_terminal() { let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); config.model_provider_id = "openai".to_string(); - config.model_reasoning_effort = Some(ReasoningEffort::High); config.model_reasoning_summary = ReasoningSummary::Detailed; config.cwd = PathBuf::from("/workspace/tests"); @@ -462,16 +537,20 @@ async fn status_snapshot_truncates_in_narrow_terminal() { let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); let token_info = token_info_for(&model_slug, &config, &usage); + let reasoning_effort_override = Some(Some(ReasoningEffort::High)); let composite = new_status_output( &config, &auth_manager, Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, &model_slug, + None, + reasoning_effort_override, ); let mut rendered_lines = render_lines(&composite.display_lines(70)); if cfg!(windows) { @@ -515,8 +594,11 @@ async fn status_snapshot_shows_missing_limits_message() { &None, None, None, + None, now, &model_slug, + None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -575,10 +657,13 @@ async fn status_snapshot_includes_credits_and_limits() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, &model_slug, + None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -626,10 +711,13 @@ async fn status_snapshot_shows_empty_limits_message() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, &model_slug, + None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -686,10 +774,13 @@ async fn status_snapshot_shows_stale_limits_message() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, now, &model_slug, + None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -750,10 +841,13 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, now, &model_slug, + None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -806,8 +900,11 @@ async fn status_context_window_uses_last_usage() { &None, None, None, + None, now, &model_slug, + None, + None, ); let rendered_lines = render_lines(&composite.display_lines(80)); let context_line = rendered_lines diff --git a/codex-rs/tui2/src/transcript_view_cache.rs b/codex-rs/tui2/src/transcript_view_cache.rs index a32094b118..ab5d94ee86 100644 --- a/codex-rs/tui2/src/transcript_view_cache.rs +++ b/codex-rs/tui2/src/transcript_view_cache.rs @@ -1018,6 +1018,8 @@ mod tests { let mut cache = TranscriptViewCache::new(); let cells: Vec> = vec![Arc::new(UserHistoryCell { message: "hello".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), })]; cache.ensure_wrapped(&cells, 20); diff --git a/codex-rs/tui2/tooltips.txt b/codex-rs/tui2/tooltips.txt index 3ba7931ff1..302a2b1ea5 100644 --- a/codex-rs/tui2/tooltips.txt +++ b/codex-rs/tui2/tooltips.txt @@ -6,7 +6,7 @@ Use /approvals to control when Codex asks for confirmation. Run /review to get a code review of your current changes. Use /skills to list available skills or ask Codex to use one. Use /status to see the current model, approvals, and token usage. -Use /fork to branch a saved chat into a new thread. +Use /fork to branch the current chat into a new thread. Use /init to create an AGENTS.md with project-specific guidance. Use /mcp to list configured MCP tools. You can run any shell command from Codex using `!` (e.g. `!ls`) diff --git a/codex-rs/utils/absolute-path/Cargo.toml b/codex-rs/utils/absolute-path/Cargo.toml index 5af9dc23aa..f4fa8bf789 100644 --- a/codex-rs/utils/absolute-path/Cargo.toml +++ b/codex-rs/utils/absolute-path/Cargo.toml @@ -9,6 +9,7 @@ license.workspace = true workspace = true [dependencies] +dirs = { workspace = true } path-absolutize = { workspace = true } schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } @@ -18,5 +19,6 @@ ts-rs = { workspace = true, features = [ ] } [dev-dependencies] +pretty_assertions = { workspace = true } serde_json = { workspace = true } tempfile = { workspace = true } diff --git a/codex-rs/utils/absolute-path/src/lib.rs b/codex-rs/utils/absolute-path/src/lib.rs index 7054103e30..966b061bf5 100644 --- a/codex-rs/utils/absolute-path/src/lib.rs +++ b/codex-rs/utils/absolute-path/src/lib.rs @@ -1,3 +1,4 @@ +use dirs::home_dir; use path_absolutize::Absolutize; use schemars::JsonSchema; use serde::Deserialize; @@ -21,16 +22,35 @@ use ts_rs::TS; pub struct AbsolutePathBuf(PathBuf); impl AbsolutePathBuf { + fn maybe_expand_home_directory(path: &Path) -> PathBuf { + let Some(path_str) = path.to_str() else { + return path.to_path_buf(); + }; + if cfg!(not(target_os = "windows")) + && let Some(home) = home_dir() + { + if path_str == "~" { + return home; + } + if let Some(rest) = path_str.strip_prefix("~/") { + return home.join(rest); + } + } + path.to_path_buf() + } + pub fn resolve_path_against_base, B: AsRef>( path: P, base_path: B, ) -> std::io::Result { - let absolute_path = path.as_ref().absolutize_from(base_path.as_ref())?; + let expanded = Self::maybe_expand_home_directory(path.as_ref()); + let absolute_path = expanded.absolutize_from(base_path.as_ref())?; Ok(Self(absolute_path.into_owned())) } pub fn from_absolute_path>(path: P) -> std::io::Result { - let absolute_path = path.as_ref().absolutize()?; + let expanded = Self::maybe_expand_home_directory(path.as_ref()); + let absolute_path = expanded.absolutize()?; Ok(Self(absolute_path.into_owned())) } @@ -165,6 +185,7 @@ impl<'de> Deserialize<'de> for AbsolutePathBuf { #[cfg(test)] mod tests { use super::*; + use pretty_assertions::assert_eq; use tempfile::tempdir; #[test] @@ -203,4 +224,47 @@ mod tests { base_dir.join(relative_path).as_path() ); } + + #[cfg(not(target_os = "windows"))] + #[test] + fn home_directory_root_on_non_windows_is_expanded_in_deserialization() { + let Some(home) = home_dir() else { + return; + }; + let temp_dir = tempdir().expect("base dir"); + let abs_path_buf = { + let _guard = AbsolutePathBufGuard::new(temp_dir.path()); + serde_json::from_str::("\"~\"").expect("failed to deserialize") + }; + assert_eq!(abs_path_buf.as_path(), home.as_path()); + } + + #[cfg(not(target_os = "windows"))] + #[test] + fn home_directory_subpath_on_non_windows_is_expanded_in_deserialization() { + let Some(home) = home_dir() else { + return; + }; + let temp_dir = tempdir().expect("base dir"); + let abs_path_buf = { + let _guard = AbsolutePathBufGuard::new(temp_dir.path()); + serde_json::from_str::("\"~/code\"").expect("failed to deserialize") + }; + assert_eq!(abs_path_buf.as_path(), home.join("code").as_path()); + } + + #[cfg(target_os = "windows")] + #[test] + fn home_directory_on_windows_is_not_expanded_in_deserialization() { + let temp_dir = tempdir().expect("base dir"); + let base_dir = temp_dir.path(); + let abs_path_buf = { + let _guard = AbsolutePathBufGuard::new(base_dir); + serde_json::from_str::("\"~/code\"").expect("failed to deserialize") + }; + assert_eq!( + abs_path_buf.as_path(), + base_dir.join("~").join("code").as_path() + ); + } } diff --git a/codex-rs/utils/pty/README.md b/codex-rs/utils/pty/README.md index 22f2e3a89b..d0f77268a2 100644 --- a/codex-rs/utils/pty/README.md +++ b/codex-rs/utils/pty/README.md @@ -6,6 +6,7 @@ Lightweight helpers for spawning interactive processes either under a PTY (pseud - `spawn_pty_process(program, args, cwd, env, arg0)` → `SpawnedProcess` - `spawn_pipe_process(program, args, cwd, env, arg0)` → `SpawnedProcess` +- `spawn_pipe_process_no_stdin(program, args, cwd, env, arg0)` → `SpawnedProcess` - `conpty_supported()` → `bool` (Windows only; always true elsewhere) - `ProcessHandle` exposes: - `writer_sender()` → `mpsc::Sender>` (stdin) @@ -46,6 +47,7 @@ let exit_code = spawned.exit_rx.await.unwrap_or(-1); ``` Swap in `spawn_pipe_process` for a non-TTY subprocess; the rest of the API stays the same. +Use `spawn_pipe_process_no_stdin` to force stdin closed (commands that read stdin will see EOF immediately). ## Tests diff --git a/codex-rs/utils/pty/src/lib.rs b/codex-rs/utils/pty/src/lib.rs index 037b0d761c..590770e2ae 100644 --- a/codex-rs/utils/pty/src/lib.rs +++ b/codex-rs/utils/pty/src/lib.rs @@ -9,6 +9,8 @@ mod win; /// Spawn a non-interactive process using regular pipes for stdin/stdout/stderr. pub use pipe::spawn_process as spawn_pipe_process; +/// Spawn a non-interactive process using regular pipes, but close stdin immediately. +pub use pipe::spawn_process_no_stdin as spawn_pipe_process_no_stdin; /// Handle for interacting with a spawned process (PTY or pipe). pub use process::ProcessHandle; /// Bundle of process handles plus output and exit receivers returned by spawn helpers. diff --git a/codex-rs/utils/pty/src/pipe.rs b/codex-rs/utils/pty/src/pipe.rs index c3dcd4ddcb..d77c11383c 100644 --- a/codex-rs/utils/pty/src/pipe.rs +++ b/codex-rs/utils/pty/src/pipe.rs @@ -90,13 +90,19 @@ where } } -/// Spawn a process using regular pipes (no PTY), returning handles for stdin, output, and exit. -pub async fn spawn_process( +#[derive(Clone, Copy)] +enum PipeStdinMode { + Piped, + Null, +} + +async fn spawn_process_with_stdin_mode( program: &str, args: &[String], cwd: &Path, env: &HashMap, arg0: &Option, + stdin_mode: PipeStdinMode, ) -> Result { if program.is_empty() { anyhow::bail!("missing program for pipe spawn"); @@ -112,7 +118,7 @@ pub async fn spawn_process( #[cfg(unix)] unsafe { command.pre_exec(move || { - crate::process_group::set_process_group()?; + crate::process_group::detach_from_tty()?; #[cfg(target_os = "linux")] crate::process_group::set_parent_death_signal(parent_pid)?; Ok(()) @@ -128,7 +134,14 @@ pub async fn spawn_process( for arg in args { command.arg(arg); } - command.stdin(Stdio::piped()); + match stdin_mode { + PipeStdinMode::Piped => { + command.stdin(Stdio::piped()); + } + PipeStdinMode::Null => { + command.stdin(Stdio::null()); + } + } command.stdout(Stdio::piped()); command.stderr(Stdio::piped()); @@ -147,18 +160,19 @@ pub async fn spawn_process( let (output_tx, _) = broadcast::channel::>(256); let initial_output_rx = output_tx.subscribe(); - let writer_handle = tokio::spawn({ - let writer = stdin.map(|w| Arc::new(tokio::sync::Mutex::new(w))); - async move { + let writer_handle = if let Some(stdin) = stdin { + let writer = Arc::new(tokio::sync::Mutex::new(stdin)); + tokio::spawn(async move { while let Some(bytes) = writer_rx.recv().await { - if let Some(writer) = &writer { - let mut guard = writer.lock().await; - let _ = guard.write_all(&bytes).await; - let _ = guard.flush().await; - } + let mut guard = writer.lock().await; + let _ = guard.write_all(&bytes).await; + let _ = guard.flush().await; } - } - }); + }) + } else { + drop(writer_rx); + tokio::spawn(async {}) + }; let stdout_handle = stdout.map(|stdout| { let output_tx = output_tx.clone(); @@ -230,3 +244,25 @@ pub async fn spawn_process( exit_rx, }) } + +/// Spawn a process using regular pipes (no PTY), returning handles for stdin, output, and exit. +pub async fn spawn_process( + program: &str, + args: &[String], + cwd: &Path, + env: &HashMap, + arg0: &Option, +) -> Result { + spawn_process_with_stdin_mode(program, args, cwd, env, arg0, PipeStdinMode::Piped).await +} + +/// Spawn a process using regular pipes, but close stdin immediately. +pub async fn spawn_process_no_stdin( + program: &str, + args: &[String], + cwd: &Path, + env: &HashMap, + arg0: &Option, +) -> Result { + spawn_process_with_stdin_mode(program, args, cwd, env, arg0, PipeStdinMode::Null).await +} diff --git a/codex-rs/utils/pty/src/process_group.rs b/codex-rs/utils/pty/src/process_group.rs index ae77a36be0..dadff29f9e 100644 --- a/codex-rs/utils/pty/src/process_group.rs +++ b/codex-rs/utils/pty/src/process_group.rs @@ -4,6 +4,8 @@ //! command can be cleaned up reliably: //! - `set_process_group` is called in `pre_exec` so the child starts its own //! process group. +//! - `detach_from_tty` starts a new session so non-interactive children do not +//! inherit the controlling TTY. //! - `kill_process_group_by_pid` targets the whole group (children/grandchildren) //! - `kill_process_group` targets a known process group ID directly //! instead of a single PID. @@ -42,6 +44,26 @@ pub fn set_parent_death_signal(_parent_pid: i32) -> io::Result<()> { Ok(()) } +#[cfg(unix)] +/// Detach from the controlling TTY by starting a new session. +pub fn detach_from_tty() -> io::Result<()> { + let result = unsafe { libc::setsid() }; + if result == -1 { + let err = io::Error::last_os_error(); + if err.raw_os_error() == Some(libc::EPERM) { + return set_process_group(); + } + return Err(err); + } + Ok(()) +} + +#[cfg(not(unix))] +/// No-op on non-Unix platforms. +pub fn detach_from_tty() -> io::Result<()> { + Ok(()) +} + #[cfg(unix)] /// Put the calling process into its own process group. /// diff --git a/codex-rs/utils/pty/src/tests.rs b/codex-rs/utils/pty/src/tests.rs index cdf9f0824a..89e72eeb4b 100644 --- a/codex-rs/utils/pty/src/tests.rs +++ b/codex-rs/utils/pty/src/tests.rs @@ -152,6 +152,49 @@ async fn pipe_process_round_trips_stdin() -> anyhow::Result<()> { Ok(()) } +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pipe_process_detaches_from_parent_session() -> anyhow::Result<()> { + let parent_sid = unsafe { libc::getsid(0) }; + if parent_sid == -1 { + anyhow::bail!("failed to read parent session id"); + } + + let env_map: HashMap = std::env::vars().collect(); + let script = "echo $$; sleep 0.2"; + let (program, args) = shell_command(script); + let spawned = spawn_pipe_process(&program, &args, Path::new("."), &env_map, &None).await?; + + let mut output_rx = spawned.output_rx; + let pid_bytes = + tokio::time::timeout(tokio::time::Duration::from_millis(500), output_rx.recv()).await??; + let pid_text = String::from_utf8_lossy(&pid_bytes); + let child_pid: i32 = pid_text + .split_whitespace() + .next() + .ok_or_else(|| anyhow::anyhow!("missing child pid output: {pid_text:?}"))? + .parse()?; + + let child_sid = unsafe { libc::getsid(child_pid) }; + if child_sid == -1 { + anyhow::bail!("failed to read child session id"); + } + + assert_eq!(child_sid, child_pid, "expected child to be session leader"); + assert_ne!( + child_sid, parent_sid, + "expected child to be detached from parent session" + ); + + let exit_code = spawned.exit_rx.await.unwrap_or(-1); + assert_eq!( + exit_code, 0, + "expected detached pipe process to exit cleanly" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn pipe_and_pty_share_interface() -> anyhow::Result<()> { let env_map: HashMap = std::env::vars().collect(); @@ -163,9 +206,11 @@ async fn pipe_and_pty_share_interface() -> anyhow::Result<()> { spawn_pipe_process(&pipe_program, &pipe_args, Path::new("."), &env_map, &None).await?; let pty = spawn_pty_process(&pty_program, &pty_args, Path::new("."), &env_map, &None).await?; + let timeout_ms = if cfg!(windows) { 10_000 } else { 3_000 }; let (pipe_out, pipe_code) = - collect_output_until_exit(pipe.output_rx, pipe.exit_rx, 3_000).await; - let (pty_out, pty_code) = collect_output_until_exit(pty.output_rx, pty.exit_rx, 3_000).await; + collect_output_until_exit(pipe.output_rx, pipe.exit_rx, timeout_ms).await; + let (pty_out, pty_code) = + collect_output_until_exit(pty.output_rx, pty.exit_rx, timeout_ms).await; assert_eq!(pipe_code, 0); assert_eq!(pty_code, 0); diff --git a/codex-rs/windows-sandbox-rs/src/allow.rs b/codex-rs/windows-sandbox-rs/src/allow.rs index 015555dab0..83d72f7e55 100644 --- a/codex-rs/windows-sandbox-rs/src/allow.rs +++ b/codex-rs/windows-sandbox-rs/src/allow.rs @@ -52,9 +52,9 @@ pub fn compute_allow_paths( let canonical = canonicalize(&candidate).unwrap_or(candidate); add_allow(canonical.clone()); - let git_dir = canonical.join(".git"); - if git_dir.is_dir() { - add_deny(git_dir); + let git_entry = canonical.join(".git"); + if git_entry.exists() { + add_deny(git_entry); } }; @@ -178,6 +178,33 @@ mod tests { assert_eq!(expected_deny, paths.deny); } + #[test] + fn denies_git_file_inside_writable_root() { + let tmp = TempDir::new().expect("tempdir"); + let command_cwd = tmp.path().join("workspace"); + let git_file = command_cwd.join(".git"); + let _ = fs::create_dir_all(&command_cwd); + let _ = fs::write(&git_file, "gitdir: .git/worktrees/example"); + + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: false, + }; + + let paths = compute_allow_paths(&policy, &command_cwd, &command_cwd, &HashMap::new()); + let expected_allow: HashSet = [dunce::canonicalize(&command_cwd).unwrap()] + .into_iter() + .collect(); + let expected_deny: HashSet = [dunce::canonicalize(&git_file).unwrap()] + .into_iter() + .collect(); + + assert_eq!(expected_allow, paths.allow); + assert_eq!(expected_deny, paths.deny); + } + #[test] fn skips_git_dir_when_missing() { let tmp = TempDir::new().expect("tempdir"); diff --git a/codex-rs/windows-sandbox-rs/src/audit.rs b/codex-rs/windows-sandbox-rs/src/audit.rs index 659ea57b6f..2e6059e819 100644 --- a/codex-rs/windows-sandbox-rs/src/audit.rs +++ b/codex-rs/windows-sandbox-rs/src/audit.rs @@ -10,6 +10,7 @@ use anyhow::anyhow; use anyhow::Result; use std::collections::HashSet; use std::ffi::c_void; +use std::ffi::OsStr; use std::path::Path; use std::path::PathBuf; use std::time::Duration; @@ -67,9 +68,9 @@ fn gather_candidates(cwd: &Path, env: &std::collections::HashMap .cloned() .or_else(|| std::env::var("PATH").ok()) { - for part in path.split(std::path::MAIN_SEPARATOR) { - if !part.is_empty() { - unique_push(&mut set, &mut out, PathBuf::from(part)); + for part in std::env::split_paths(OsStr::new(&path)) { + if !part.as_os_str().is_empty() { + unique_push(&mut set, &mut out, part); } } } @@ -298,3 +299,41 @@ pub fn apply_capability_denies_for_world_writable( } Ok(()) } + +#[cfg(test)] +mod tests { + use super::gather_candidates; + use std::collections::HashMap; + use std::fs; + + #[test] + fn gathers_path_entries_by_list_separator() { + let tmp = tempfile::tempdir().expect("tempdir"); + let dir_a = tmp.path().join("Tools"); + let dir_b = tmp.path().join("Bin"); + let dir_space = tmp.path().join("Program Files"); + fs::create_dir_all(&dir_a).expect("dir a"); + fs::create_dir_all(&dir_b).expect("dir b"); + fs::create_dir_all(&dir_space).expect("dir space"); + + let mut env_map = HashMap::new(); + env_map.insert( + "PATH".to_string(), + format!( + "{};{};{}", + dir_a.display(), + dir_b.display(), + dir_space.display() + ), + ); + + let candidates = gather_candidates(tmp.path(), &env_map); + let canon_a = dir_a.canonicalize().expect("canon a"); + let canon_b = dir_b.canonicalize().expect("canon b"); + let canon_space = dir_space.canonicalize().expect("canon space"); + + assert!(candidates.contains(&canon_a)); + assert!(candidates.contains(&canon_b)); + assert!(candidates.contains(&canon_space)); + } +} diff --git a/codex-rs/windows-sandbox-rs/src/sandbox_users.rs b/codex-rs/windows-sandbox-rs/src/sandbox_users.rs index 41298b8fba..8aa67a41a5 100644 --- a/codex-rs/windows-sandbox-rs/src/sandbox_users.rs +++ b/codex-rs/windows-sandbox-rs/src/sandbox_users.rs @@ -13,6 +13,7 @@ use std::fs::File; use std::path::Path; use std::path::PathBuf; use windows_sys::Win32::Foundation::GetLastError; +use windows_sys::Win32::Foundation::LocalFree; use windows_sys::Win32::Foundation::ERROR_INSUFFICIENT_BUFFER; use windows_sys::Win32::NetworkManagement::NetManagement::NERR_Success; use windows_sys::Win32::NetworkManagement::NetManagement::NetLocalGroupAdd; @@ -27,7 +28,10 @@ use windows_sys::Win32::NetworkManagement::NetManagement::USER_INFO_1; use windows_sys::Win32::NetworkManagement::NetManagement::USER_INFO_1003; use windows_sys::Win32::NetworkManagement::NetManagement::USER_PRIV_USER; use windows_sys::Win32::Security::Authorization::ConvertStringSidToSidW; +use windows_sys::Win32::Security::CopySid; +use windows_sys::Win32::Security::GetLengthSid; use windows_sys::Win32::Security::LookupAccountNameW; +use windows_sys::Win32::Security::LookupAccountSidW; use windows_sys::Win32::Security::SID_NAME_USE; use codex_windows_sandbox::dpapi_protect; @@ -38,6 +42,11 @@ use codex_windows_sandbox::SETUP_VERSION; pub const SANDBOX_USERS_GROUP: &str = "CodexSandboxUsers"; const SANDBOX_USERS_GROUP_COMMENT: &str = "Codex sandbox internal group (managed)"; +const SID_ADMINISTRATORS: &str = "S-1-5-32-544"; +const SID_USERS: &str = "S-1-5-32-545"; +const SID_AUTHENTICATED_USERS: &str = "S-1-5-11"; +const SID_EVERYONE: &str = "S-1-1-0"; +const SID_SYSTEM: &str = "S-1-5-18"; pub fn ensure_sandbox_users_group(log: &mut File) -> Result<()> { ensure_local_group(SANDBOX_USERS_GROUP, SANDBOX_USERS_GROUP_COMMENT, log) @@ -119,17 +128,24 @@ pub fn ensure_local_user(name: &str, password: &str, log: &mut File) -> Result<( } // Ensure the principal is a regular local user account. - let group = to_wide(OsStr::new("Users")); - let member = LOCALGROUP_MEMBERS_INFO_3 { - lgrmi3_domainandname: name_w.as_ptr() as *mut u16, - }; - let _ = NetLocalGroupAddMembers( - std::ptr::null(), - group.as_ptr(), - 3, - &member as *const _ as *mut u8, - 1, - ); + if let Ok(group_name) = lookup_account_name_for_sid(SID_USERS) { + let group = to_wide(OsStr::new(&group_name)); + let member = LOCALGROUP_MEMBERS_INFO_3 { + lgrmi3_domainandname: name_w.as_ptr() as *mut u16, + }; + let _ = NetLocalGroupAddMembers( + std::ptr::null(), + group.as_ptr(), + 3, + &member as *const _ as *mut u8, + 1, + ); + } else { + super::log_line( + log, + "LookupAccountSidW failed for Users SID; skipping Users group membership", + )?; + } } Ok(()) } @@ -184,6 +200,9 @@ pub fn ensure_local_group_member(group_name: &str, member_name: &str) -> Result< } pub fn resolve_sid(name: &str) -> Result> { + if let Some(sid_str) = well_known_sid_str(name) { + return sid_bytes_from_string(sid_str); + } let name_w = to_wide(OsStr::new(name)); let mut sid_buffer = vec![0u8; 68]; let mut sid_len: u32 = sid_buffer.len() as u32; @@ -218,6 +237,104 @@ pub fn resolve_sid(name: &str) -> Result> { } } +fn well_known_sid_str(name: &str) -> Option<&'static str> { + match name { + "Administrators" => Some(SID_ADMINISTRATORS), + "Users" => Some(SID_USERS), + "Authenticated Users" => Some(SID_AUTHENTICATED_USERS), + "Everyone" => Some(SID_EVERYONE), + "SYSTEM" => Some(SID_SYSTEM), + _ => None, + } +} + +fn sid_bytes_from_string(sid_str: &str) -> Result> { + let sid_w = to_wide(OsStr::new(sid_str)); + let mut psid: *mut c_void = std::ptr::null_mut(); + if unsafe { ConvertStringSidToSidW(sid_w.as_ptr(), &mut psid) } == 0 { + return Err(anyhow::anyhow!( + "ConvertStringSidToSidW failed for {sid_str}: {}", + unsafe { GetLastError() } + )); + } + let sid_len = unsafe { GetLengthSid(psid) }; + if sid_len == 0 { + unsafe { + LocalFree(psid as _); + } + return Err(anyhow::anyhow!("GetLengthSid failed for {sid_str}")); + } + let mut out = vec![0u8; sid_len as usize]; + let ok = unsafe { CopySid(sid_len, out.as_mut_ptr() as *mut c_void, psid) }; + unsafe { + LocalFree(psid as _); + } + if ok == 0 { + return Err(anyhow::anyhow!("CopySid failed for {sid_str}")); + } + Ok(out) +} + +fn lookup_account_name_for_sid(sid_str: &str) -> Result { + let sid_w = to_wide(OsStr::new(sid_str)); + let mut psid: *mut c_void = std::ptr::null_mut(); + if unsafe { ConvertStringSidToSidW(sid_w.as_ptr(), &mut psid) } == 0 { + return Err(anyhow::anyhow!( + "ConvertStringSidToSidW failed for {sid_str}: {}", + unsafe { GetLastError() } + )); + } + let mut name_len: u32 = 0; + let mut domain_len: u32 = 0; + let mut use_type: SID_NAME_USE = 0; + let ok = unsafe { + LookupAccountSidW( + std::ptr::null(), + psid, + std::ptr::null_mut(), + &mut name_len, + std::ptr::null_mut(), + &mut domain_len, + &mut use_type, + ) + }; + if ok == 0 { + let err = unsafe { GetLastError() }; + if err != ERROR_INSUFFICIENT_BUFFER { + unsafe { + LocalFree(psid as _); + } + return Err(anyhow::anyhow!( + "LookupAccountSidW preflight failed for {sid_str}: {err}" + )); + } + } + let mut name_buf: Vec = vec![0u16; name_len as usize]; + let mut domain_buf: Vec = vec![0u16; domain_len as usize]; + let ok = unsafe { + LookupAccountSidW( + std::ptr::null(), + psid, + name_buf.as_mut_ptr(), + &mut name_len, + domain_buf.as_mut_ptr(), + &mut domain_len, + &mut use_type, + ) + }; + unsafe { + LocalFree(psid as _); + } + if ok == 0 { + return Err(anyhow::anyhow!( + "LookupAccountSidW failed for {sid_str}: {}", + unsafe { GetLastError() } + )); + } + let name = String::from_utf16_lossy(&name_buf); + Ok(name.trim_end_matches('\0').to_string()) +} + pub fn sid_bytes_to_psid(sid: &[u8]) -> Result<*mut c_void> { let sid_str = string_from_sid_bytes(sid).map_err(anyhow::Error::msg)?; let sid_w = to_wide(OsStr::new(&sid_str)); diff --git a/codex-rs/windows-sandbox-rs/src/setup_main_win.rs b/codex-rs/windows-sandbox-rs/src/setup_main_win.rs index e1b914859e..52e60a1da7 100644 --- a/codex-rs/windows-sandbox-rs/src/setup_main_win.rs +++ b/codex-rs/windows-sandbox-rs/src/setup_main_win.rs @@ -76,19 +76,14 @@ struct Payload { refresh_only: bool, } -#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)] #[serde(rename_all = "kebab-case")] enum SetupMode { + #[default] Full, ReadAclsOnly, } -impl Default for SetupMode { - fn default() -> Self { - Self::Full - } -} - fn log_line(log: &mut File, msg: &str) -> Result<()> { let ts = chrono::Utc::now().to_rfc3339(); writeln!(log, "[{ts}] {msg}")?; diff --git a/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs b/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs index 30907de7c8..7578e494cd 100644 --- a/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs +++ b/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs @@ -26,7 +26,7 @@ use windows_sys::Win32::Security::CheckTokenMembership; use windows_sys::Win32::Security::FreeSid; use windows_sys::Win32::Security::SECURITY_NT_AUTHORITY; -pub const SETUP_VERSION: u32 = 3; +pub const SETUP_VERSION: u32 = 4; pub const OFFLINE_USERNAME: &str = "CodexSandboxOffline"; pub const ONLINE_USERNAME: &str = "CodexSandboxOnline"; const SECURITY_BUILTIN_DOMAIN_RID: u32 = 0x0000_0020;