diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 6707e75b..49f4a148 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -14,9 +14,9 @@ dependencies = [ [[package]] name = "addr2line" -version = "0.24.2" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" dependencies = [ "gimli", ] @@ -69,7 +69,7 @@ checksum = "fe233a377643e0fc1a56421d7c90acdec45c291b30345eb9f08e8d0ddce5a4ab" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -111,9 +111,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.19" +version = "0.6.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933" +checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192" dependencies = [ "anstyle", "anstyle-parse", @@ -141,29 +141,29 @@ dependencies = [ [[package]] name = "anstyle-query" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.9" +version = "3.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] name = "anyhow" -version = "1.0.99" +version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "app_test_support" @@ -181,9 +181,9 @@ dependencies = [ [[package]] name = "arboard" -version = "3.6.0" +version = "3.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55f533f8e0af236ffe5eb979b99381df3258853f00ba2e44b6e1955292c75227" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" dependencies = [ "clipboard-win", "image", @@ -195,7 +195,7 @@ dependencies = [ "objc2-foundation", "parking_lot", "percent-encoding", - "windows-sys 0.59.0", + "windows-sys 0.60.2", "x11rb", ] @@ -246,7 +246,7 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -321,7 +321,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -332,7 +332,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -349,9 +349,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "axum" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" +checksum = "98e529aee37b5c8206bb4bf4c44797127566d72f76952c970bd3d1e85de8f4e2" dependencies = [ "axum-core", "bytes", @@ -367,8 +367,7 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "rustversion", - "serde", + "serde_core", "sync_wrapper", "tokio", "tower", @@ -378,9 +377,9 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.5.2" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68464cd0412f486726fb3373129ef5d2993f90c34bc2bc1c1e9943b2f4fc7ca6" +checksum = "0ac7a6beb1182c7e30253ee75c3e918080bfb83f5a3023bcdf7209d85fd147e6" dependencies = [ "bytes", "futures-core", @@ -389,7 +388,6 @@ dependencies = [ "http-body-util", "mime", "pin-project-lite", - "rustversion", "sync_wrapper", "tower-layer", "tower-service", @@ -397,9 +395,9 @@ dependencies = [ [[package]] name = "backtrace" -version = "0.3.75" +version = "0.3.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" dependencies = [ "addr2line", "cfg-if", @@ -407,7 +405,7 @@ dependencies = [ "miniz_oxide", "object", "rustc-demangle", - "windows-targets 0.52.6", + "windows-link 0.2.0", ] [[package]] @@ -454,9 +452,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.1" +version = "2.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" [[package]] name = "block-buffer" @@ -486,9 +484,9 @@ checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" [[package]] name = "bytemuck" -version = "1.23.1" +version = "1.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c76a5792e44e4abe34d3abf15636779261d45a7450612059293d1d2cfc63422" +checksum = "3995eaeebcdf32f91f980d360f78732ddc061097ab4e39991ae7a6ace9194677" [[package]] name = "byteorder" @@ -525,10 +523,11 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.30" +version = "1.2.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "deec109607ca693028562ed836a5f1c4b8bd77755c4e132fc5ce11b0b6211ae7" +checksum = "e1354349954c6fc9cb0deab020f27f783cf0b604e8bb754dc4658ecf0d29c35f" dependencies = [ + "find-msvc-tools", "shlex", ] @@ -540,9 +539,9 @@ checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" [[package]] name = "cfg-if" -version = "1.0.1" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" [[package]] name = "cfg_aliases" @@ -578,9 +577,9 @@ checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" [[package]] name = "clap" -version = "4.5.47" +version = "4.5.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eac00902d9d136acd712710d71823fb8ac8004ca445a89e73a41d45aa712931" +checksum = "e2134bb3ea021b78629caa971416385309e0131b351b25e01dc16fb54e1b5fae" dependencies = [ "clap_builder", "clap_derive", @@ -588,9 +587,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.47" +version = "4.5.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ad9bbf750e73b5884fb8a211a9424a1906c1e156724260fdae972f31d70e1d6" +checksum = "c2ba64afa3c0a6df7fa517765e31314e983f51dda798ffba27b988194fb65dc9" dependencies = [ "anstream", "anstyle", @@ -601,9 +600,9 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.5.57" +version = "4.5.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d9501bd3f5f09f7bbee01da9a511073ed30a80cd7a509f1214bb74eadea71ad" +checksum = "75bf0b32ad2e152de789bb635ea4d3078f6b838ad7974143e99b99f45a04af4a" dependencies = [ "clap", ] @@ -617,7 +616,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -707,6 +706,26 @@ dependencies = [ "tokio", ] +[[package]] +name = "codex-backend-client" +version = "0.0.0" +dependencies = [ + "anyhow", + "codex-backend-openapi-models", + "pretty_assertions", + "reqwest", + "serde", + "serde_json", +] + +[[package]] +name = "codex-backend-openapi-models" +version = "0.0.0" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "codex-chatgpt" version = "0.0.0" @@ -715,6 +734,7 @@ dependencies = [ "clap", "codex-common", "codex-core", + "codex-git-apply", "serde", "serde_json", "tempfile", @@ -732,6 +752,7 @@ dependencies = [ "codex-app-server", "codex-arg0", "codex-chatgpt", + "codex-cloud-tasks", "codex-common", "codex-core", "codex-exec", @@ -740,6 +761,7 @@ dependencies = [ "codex-process-hardening", "codex-protocol", "codex-protocol-ts", + "codex-responses-api-proxy", "codex-tui", "ctor 0.5.0", "owo-colors", @@ -753,6 +775,48 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "codex-cloud-tasks" +version = "0.0.0" +dependencies = [ + "anyhow", + "async-trait", + "base64", + "chrono", + "clap", + "codex-cloud-tasks-client", + "codex-common", + "codex-core", + "codex-login", + "codex-tui", + "crossterm", + "ratatui", + "reqwest", + "serde", + "serde_json", + "throbber-widgets-tui", + "tokio", + "tokio-stream", + "tracing", + "tracing-subscriber", + "unicode-width 0.1.14", +] + +[[package]] +name = "codex-cloud-tasks-client" +version = "0.0.0" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "codex-backend-client", + "codex-git-apply", + "diffy", + "serde", + "serde_json", + "thiserror 2.0.16", +] + [[package]] name = "codex-common" version = "0.0.0" @@ -788,7 +852,7 @@ dependencies = [ "escargot", "eventsource-stream", "futures", - "indexmap 2.10.0", + "indexmap 2.11.4", "landlock", "libc", "maplit", @@ -798,7 +862,7 @@ dependencies = [ "portable-pty", "predicates", "pretty_assertions", - "rand", + "rand 0.9.2", "regex-lite", "reqwest", "seccompiler", @@ -893,6 +957,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "codex-git-apply" +version = "0.0.0" +dependencies = [ + "once_cell", + "regex", + "tempfile", +] + [[package]] name = "codex-git-tooling" version = "0.0.0" @@ -926,7 +999,7 @@ dependencies = [ "codex-core", "codex-protocol", "core_test_support", - "rand", + "rand 0.9.2", "reqwest", "serde", "serde_json", @@ -1123,7 +1196,7 @@ dependencies = [ "pathdiff", "pretty_assertions", "pulldown-cmark", - "rand", + "rand 0.9.2", "ratatui", "regex-lite", "serde", @@ -1348,7 +1421,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "crossterm_winapi", "futures-core", "mio", @@ -1441,7 +1514,7 @@ dependencies = [ "proc-macro2", "quote", "strsim 0.11.1", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1455,7 +1528,7 @@ dependencies = [ "proc-macro2", "quote", "strsim 0.11.1", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1466,7 +1539,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1477,7 +1550,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core 0.21.3", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1557,7 +1630,7 @@ dependencies = [ "convert_case", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", "unicode-xid", ] @@ -1569,7 +1642,7 @@ checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", "unicode-xid", ] @@ -1631,8 +1704,8 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users 0.5.0", - "windows-sys 0.60.2", + "redox_users 0.5.2", + "windows-sys 0.61.1", ] [[package]] @@ -1652,7 +1725,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "objc2", ] @@ -1674,7 +1747,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1727,14 +1800,14 @@ checksum = "83e195b4945e88836d826124af44fdcb262ec01ef94d44f14f4fb5103f19892a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] name = "dyn-clone" -version = "1.0.19" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "either" @@ -1789,7 +1862,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1838,12 +1911,12 @@ dependencies = [ [[package]] name = "errno" -version = "0.3.13" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.1", ] [[package]] @@ -1865,9 +1938,9 @@ dependencies = [ [[package]] name = "event-listener" -version = "5.4.0" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3492acde4c3fc54c845eaab3eed8bd00c7a7d881f78bfc801e43a93dec1331ae" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" dependencies = [ "concurrent-queue", "parking", @@ -1928,7 +2001,7 @@ checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1938,7 +2011,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", - "rustix 1.0.8", + "rustix 1.1.2", "windows-sys 0.59.0", ] @@ -1962,6 +2035,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ced73b1dacfc750a6db6c0a0c3a3853c8b41997e2e2c563dc90804ae6867959" + [[package]] name = "fixed_decimal" version = "0.7.0" @@ -2027,9 +2106,9 @@ checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] name = "form_urlencoded" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] @@ -2090,7 +2169,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -2144,19 +2223,19 @@ dependencies = [ [[package]] name = "gethostname" -version = "0.4.3" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0176e0459c2e4a1fe232f984bca6890e681076abb9934f6cea7c326f3fc47818" +checksum = "fc257fdb4038301ce4b9cd1b3b51704509692bb3ff716a410cbd07925d9dae55" dependencies = [ - "libc", - "windows-targets 0.48.5", + "rustix 1.1.2", + "windows-targets 0.52.6", ] [[package]] name = "getopts" -version = "0.2.23" +version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cba6ae63eb948698e300f645f87c70f76630d505f23b8907cf1e193ee85048c1" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" dependencies = [ "unicode-width 0.2.1", ] @@ -2184,15 +2263,15 @@ dependencies = [ "js-sys", "libc", "r-efi", - "wasi 0.14.2+wasi-0.2.4", + "wasi 0.14.7+wasi-0.2.4", "wasm-bindgen", ] [[package]] name = "gimli" -version = "0.31.1" +version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" [[package]] name = "globset" @@ -2204,14 +2283,14 @@ dependencies = [ "bstr", "log", "regex-automata", - "regex-syntax 0.8.5", + "regex-syntax 0.8.6", ] [[package]] name = "h2" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17da50a276f1e01e0ba6c029e47b7100754904ee8a278f886546e98575380785" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" dependencies = [ "atomic-waker", "bytes", @@ -2219,7 +2298,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap 2.10.0", + "indexmap 2.11.4", "slab", "tokio", "tokio-util", @@ -2254,15 +2333,21 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.15.4" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", "foldhash", ] +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" + [[package]] name = "heck" version = "0.5.0" @@ -2417,9 +2502,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" +checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" dependencies = [ "base64", "bytes", @@ -2443,9 +2528,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.63" +version = "0.1.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -2453,7 +2538,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.62.1", ] [[package]] @@ -2604,9 +2689,9 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "idna" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", "smallvec", @@ -2657,9 +2742,9 @@ dependencies = [ [[package]] name = "indenter" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" [[package]] name = "indexmap" @@ -2674,13 +2759,14 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.10.0" +version = "2.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" +checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", - "hashbrown 0.15.4", + "hashbrown 0.16.0", "serde", + "serde_core", ] [[package]] @@ -2710,25 +2796,25 @@ dependencies = [ "indoc", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] name = "inventory" -version = "0.3.20" +version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab08d7cd2c5897f2c949e5383ea7c7db03fb19130ffcfbf7eda795137ae3cb83" +checksum = "bc61209c082fbeb19919bee74b176221b27223e27b65d781eb91af24eb1fb46e" dependencies = [ "rustversion", ] [[package]] name = "io-uring" -version = "0.7.9" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" +checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "cfg-if", "libc", ] @@ -2826,7 +2912,7 @@ checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -2853,9 +2939,9 @@ checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" [[package]] name = "js-sys" -version = "0.3.77" +version = "0.3.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305" dependencies = [ "once_cell", "wasm-bindgen", @@ -2894,9 +2980,9 @@ dependencies = [ [[package]] name = "landlock" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3d2ef408b88e913bfc6594f5e693d57676f6463ded7d8bf994175364320c706" +checksum = "affe8b77dce5b172f8e290bd801b12832a77cd1942d1ea98259916e89d5829d6" dependencies = [ "enumflags2", "libc", @@ -2911,9 +2997,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.175" +version = "0.2.176" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" +checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" [[package]] name = "libm" @@ -2923,11 +3009,11 @@ checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" [[package]] name = "libredox" -version = "0.1.6" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4488594b9328dee448adb906d8b126d9b7deb7cf5c22161ee591610bb1be83c0" +checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "libc", ] @@ -2939,9 +3025,9 @@ checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "linux-raw-sys" -version = "0.9.4" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "litemap" @@ -2994,7 +3080,7 @@ version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" dependencies = [ - "hashbrown 0.15.4", + "hashbrown 0.15.5", ] [[package]] @@ -3065,9 +3151,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.7.5" +version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "memoffset" @@ -3185,7 +3271,7 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "cfg-if", "cfg_aliases 0.1.1", "libc", @@ -3197,7 +3283,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "cfg-if", "cfg_aliases 0.2.1", "libc", @@ -3306,7 +3392,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "objc2", "objc2-core-graphics", "objc2-foundation", @@ -3318,7 +3404,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "dispatch2", "objc2", ] @@ -3329,7 +3415,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "989c6c68c13021b5c2d6b71456ebb0f9dc78d752e86a98da7c716f4f9470f5a4" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "dispatch2", "objc2", "objc2-core-foundation", @@ -3348,7 +3434,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "objc2", "objc2-core-foundation", ] @@ -3359,16 +3445,16 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7282e9ac92529fa3457ce90ebb15f4ecbc383e8338060960760fa2cf75420c3c" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "objc2", "objc2-core-foundation", ] [[package]] name = "object" -version = "0.36.7" +version = "0.37.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" dependencies = [ "memchr", ] @@ -3391,7 +3477,7 @@ version = "0.10.73" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "cfg-if", "foreign-types", "libc", @@ -3408,7 +3494,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -3419,9 +3505,9 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" [[package]] name = "openssl-src" -version = "300.5.1+3.5.1" +version = "300.5.2+3.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "735230c832b28c000e3bc117119e6466a663ec73506bc0a9907ea4187508e42a" +checksum = "d270b79e2926f5150189d475bc7e9d2c69f9c4697b185fa917d5a32b792d21b4" dependencies = [ "cc", ] @@ -3530,7 +3616,7 @@ dependencies = [ "futures-util", "opentelemetry", "percent-encoding", - "rand", + "rand 0.9.2", "serde_json", "thiserror 2.0.16", "tokio", @@ -3628,9 +3714,9 @@ checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" [[package]] name = "percent-encoding" -version = "2.3.1" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "petgraph" @@ -3639,7 +3725,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ "fixedbitset", - "indexmap 2.10.0", + "indexmap 2.11.4", ] [[package]] @@ -3668,7 +3754,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -3691,12 +3777,12 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "plist" -version = "1.7.4" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3af6b589e163c5a788fab00ce0c0366f6efbb9959c2f9874b224936af7fce7e1" +checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64", - "indexmap 2.10.0", + "indexmap 2.11.4", "quick-xml", "serde", "time", @@ -3708,7 +3794,7 @@ version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "crc32fast", "fdeflate", "flate2", @@ -3753,9 +3839,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" +checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" dependencies = [ "serde", "zerovec", @@ -3824,9 +3910,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.95" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" dependencies = [ "unicode-ident", ] @@ -3838,7 +3924,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3ef4f2f0422f23a82ec9f628ea2acd12871c81a9362b02c43c1aa86acfc3ba1" dependencies = [ "futures", - "indexmap 2.10.0", + "indexmap 2.11.4", "nix 0.30.1", "tokio", "tracing", @@ -3865,7 +3951,7 @@ dependencies = [ "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -3874,7 +3960,7 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76979bea66e7875e7509c4ec5300112b316af87fa7a252ca91c448b32dfe3993" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "getopts", "memchr", "pulldown-cmark-escape", @@ -3889,9 +3975,9 @@ checksum = "bd348ff538bc9caeda7ee8cad2d1d48236a1f443c1fa3913c6a02fe0043b1dd3" [[package]] name = "pxfm" -version = "0.1.23" +version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f55f4fedc84ed39cb7a489322318976425e42a147e2be79d8f878e2884f94e84" +checksum = "83f9b339b02259ada5c0f4a389b7fb472f933aa17ce176fd2ad98f28bb401fde" dependencies = [ "num-traits", ] @@ -3904,9 +3990,9 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] name = "quick-xml" -version = "0.38.0" +version = "0.38.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8927b0664f5c5a98265138b7e3f90aa19a6b21353182469ace36d4ac527b7b1b" +checksum = "42a232e7487fc2ef313d96dde7948e7a3c05101870d8985e4fd8d26aedd27b89" dependencies = [ "memchr", ] @@ -3940,7 +4026,7 @@ dependencies = [ "bytes", "getrandom 0.3.3", "lru-slab", - "rand", + "rand 0.9.2", "ring", "rustc-hash", "rustls", @@ -3991,14 +4077,35 @@ dependencies = [ "nibble_vec", ] +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ - "rand_chacha", - "rand_core", + "rand_chacha 0.9.0", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", ] [[package]] @@ -4008,7 +4115,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", ] [[package]] @@ -4025,7 +4141,7 @@ name = "ratatui" version = "0.29.0" source = "git+https://github.com/nornagon/ratatui?branch=nornagon-v0.29.0-patch#9b2ad1298408c45918ee9f8241a6f95498cdbed2" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "cassowary", "compact_str", "crossterm", @@ -4042,11 +4158,11 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.15" +version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e8af0dde094006011e6a740d4879319439489813bd0bcdc7d821beaeeff48ec" +checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", ] [[package]] @@ -4062,9 +4178,9 @@ dependencies = [ [[package]] name = "redox_users" -version = "0.5.0" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.16", "libredox", @@ -4088,30 +4204,30 @@ checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] name = "regex" -version = "1.11.1" +version = "1.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "8b5288124840bee7b386bc413c487869b360b2b4ec421ea56425128692f2a82c" dependencies = [ "aho-corasick", "memchr", "regex-automata", - "regex-syntax 0.8.5", + "regex-syntax 0.8.6", ] [[package]] name = "regex-automata" -version = "0.4.9" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "833eb9ce86d40ef33cb1306d8accf7bc8ec2bfea4355cbdebb3df68b40925cad" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.5", + "regex-syntax 0.8.6", ] [[package]] @@ -4128,9 +4244,9 @@ checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" [[package]] name = "regex-syntax" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" [[package]] name = "reqwest" @@ -4211,7 +4327,7 @@ dependencies = [ "paste", "pin-project-lite", "process-wrap", - "rand", + "rand 0.9.2", "reqwest", "rmcp-macros", "schemars 1.0.4", @@ -4237,14 +4353,14 @@ dependencies = [ "proc-macro2", "quote", "serde_json", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] name = "rustc-demangle" -version = "0.1.25" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" [[package]] name = "rustc-hash" @@ -4258,7 +4374,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "errno", "libc", "linux-raw-sys 0.4.15", @@ -4267,22 +4383,22 @@ dependencies = [ [[package]] name = "rustix" -version = "1.0.8" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "errno", "libc", - "linux-raw-sys 0.9.4", - "windows-sys 0.60.2", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.1", ] [[package]] name = "rustls" -version = "0.23.29" +version = "0.23.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2491382039b29b9b11ff08b76ff6c97cf287671dbb74f0be44bda389fffe9bd1" +checksum = "cd3c25631629d034ce7cd9940adc9d45762d46de2b0f57193c4443b92c6d4d40" dependencies = [ "once_cell", "ring", @@ -4316,9 +4432,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.4" +version = "0.103.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc" +checksum = "8572f3c2cb9934231157b45499fc41e1f58c589fdfb81a844ba873265e80f8eb" dependencies = [ "ring", "rustls-pki-types", @@ -4327,9 +4443,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.21" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "rustyline" @@ -4337,7 +4453,7 @@ version = "14.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7803e8936da37efd9b6d4478277f4b2b9bb5cdb37a113e8d63222e58da647e63" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "cfg-if", "clipboard-win", "fd-lock", @@ -4370,11 +4486,11 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.27" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.1", ] [[package]] @@ -4466,7 +4582,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -4478,7 +4594,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -4502,7 +4618,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -4515,7 +4631,7 @@ version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "80fb1d92c5028aa318b4b8bd7302a5bfcf48be96a37fc6fc790f806b0004ee0c" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -4524,9 +4640,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.14.0" +version = "2.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" dependencies = [ "core-foundation-sys", "libc", @@ -4534,9 +4650,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.226" +version = "1.0.227" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dca6411025b24b60bfa7ec1fe1f8e710ac09782dca409ee8237ba74b51295fd" +checksum = "80ece43fc6fbed4eb5392ab50c07334d3e577cbf40997ee896fe7af40bba4245" dependencies = [ "serde_core", "serde_derive", @@ -4544,22 +4660,22 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.226" +version = "1.0.227" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba2ba63999edb9dac981fb34b3e5c0d111a69b0924e253ed29d83f7c99e966a4" +checksum = "7a576275b607a2c86ea29e410193df32bc680303c82f31e275bbfcafe8b33be5" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.226" +version = "1.0.227" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8db53ae22f34573731bafa1db20f04027b2d25e02d8205921b569171699cdb33" +checksum = "51e694923b8824cf0e9b382adf0f60d4e05f348f357b38833a3fa5ed7c2ede04" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -4570,7 +4686,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -4579,7 +4695,7 @@ version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ - "indexmap 2.10.0", + "indexmap 2.11.4", "itoa", "memchr", "ryu", @@ -4595,16 +4711,16 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] name = "serde_spanned" -version = "1.0.0" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40734c41988f7306bb04f0ecf60ec0f3f1caa34290e4e8ea471dcd3346483b83" +checksum = "5417783452c2be558477e104686f7de5dae53dba813c28435e0e70f82d9b04ee" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -4621,15 +4737,15 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.14.0" +version = "3.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2c45cd61fefa9db6f254525d46e392b852e0e61d9a1fd36e5bd183450a556d5" +checksum = "c522100790450cf78eeac1507263d0a350d4d5b30df0c8e1fe051a10c22b376e" dependencies = [ "base64", "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.10.0", + "indexmap 2.11.4", "schemars 0.9.0", "schemars 1.0.4", "serde", @@ -4641,21 +4757,21 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.14.0" +version = "3.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de90945e6565ce0d9a25098082ed4ee4002e047cb59892c318d66821e14bb30f" +checksum = "327ada00f7d64abaac1e55a6911e90cf665aa051b9a561c7006c157f4633135e" dependencies = [ - "darling 0.20.11", + "darling 0.21.3", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] name = "serial2" -version = "0.2.31" +version = "0.2.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26e1e5956803a69ddd72ce2de337b577898801528749565def03515f82bad5bb" +checksum = "8cc76fa68e25e771492ca1e3c53d447ef0be3093e05cd3b47f4b712ba10c6f3c" dependencies = [ "cfg-if", "libc", @@ -4738,9 +4854,9 @@ dependencies = [ [[package]] name = "signal-hook-registry" -version = "1.4.5" +version = "1.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" dependencies = [ "libc", ] @@ -4875,7 +4991,7 @@ dependencies = [ "dupe", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -4977,7 +5093,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -4989,7 +5105,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -5020,9 +5136,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.104" +version = "2.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" dependencies = [ "proc-macro2", "quote", @@ -5046,7 +5162,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -5064,7 +5180,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -5088,8 +5204,8 @@ dependencies = [ "fastrand", "getrandom 0.3.3", "once_cell", - "rustix 1.0.8", - "windows-sys 0.60.2", + "rustix 1.1.2", + "windows-sys 0.61.1", ] [[package]] @@ -5114,12 +5230,12 @@ dependencies = [ [[package]] name = "terminal_size" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45c6481c4829e4cc63825e62c49186a34538b7b2750b73b266581ffb612fb5ed" +checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" dependencies = [ - "rustix 1.0.8", - "windows-sys 0.59.0", + "rustix 1.1.2", + "windows-sys 0.60.2", ] [[package]] @@ -5174,7 +5290,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -5185,7 +5301,7 @@ checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -5197,6 +5313,16 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "throbber-widgets-tui" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d36b5738d666a2b4c91b7c24998a8588db724b3107258343ebf8824bf55b06d" +dependencies = [ + "rand 0.8.5", + "ratatui", +] + [[package]] name = "tiff" version = "0.10.3" @@ -5318,7 +5444,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -5333,9 +5459,9 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.2" +version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ "rustls", "tokio", @@ -5380,12 +5506,12 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.5" +version = "0.9.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75129e1dc5000bfbaa9fee9d1b21f974f9fbad9daec557a521ee6e080825f6e8" +checksum = "00e5e5d9bf2475ac9d4f0d9edab68cc573dc2fd644b0dba36b0c30a92dd9eaa0" dependencies = [ - "indexmap 2.10.0", - "serde", + "indexmap 2.11.4", + "serde_core", "serde_spanned", "toml_datetime", "toml_parser", @@ -5395,20 +5521,20 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.0" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bade1c3e902f58d73d3f294cd7f20391c1cb2fbcb643b73566bc773971df91e3" +checksum = "32f1085dec27c2b6632b04c80b3bb1b4300d6495d1e129693bdda7d91e72eec1" dependencies = [ - "serde", + "serde_core", ] [[package]] name = "toml_edit" -version = "0.23.4" +version = "0.23.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7211ff1b8f0d3adae1663b7da9ffe396eabe1ca25f0b0bee42b0da29a9ddce93" +checksum = "f3effe7c0e86fdff4f69cdd2ccc1b96f933e24811c5441d44904e8683e27184b" dependencies = [ - "indexmap 2.10.0", + "indexmap 2.11.4", "toml_datetime", "toml_parser", "toml_writer", @@ -5417,18 +5543,18 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b551886f449aa90d4fe2bdaa9f4a2577ad2dde302c61ecf262d80b116db95c10" +checksum = "4cf893c33be71572e0e9aa6dd15e6677937abd686b066eac3f8cd3531688a627" dependencies = [ "winnow", ] [[package]] name = "toml_writer" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcc842091f2def52017664b53082ecbbeb5c7731092bad69d2c63050401dfd64" +checksum = "d163a63c116ce562a22cda521fcc4d79152e7aba014456fb5eb442f6d6a10109" [[package]] name = "tonic" @@ -5467,7 +5593,7 @@ checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" dependencies = [ "futures-core", "futures-util", - "indexmap 2.10.0", + "indexmap 2.11.4", "pin-project-lite", "slab", "sync_wrapper", @@ -5484,7 +5610,7 @@ version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "bytes", "futures-util", "http", @@ -5540,7 +5666,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -5610,18 +5736,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04659ddb06c87d233c566112c1c9c5b9e98256d9af50ec3bc9c8327f873a7568" dependencies = [ "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] name = "tree-sitter" -version = "0.25.9" +version = "0.25.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccd2a058a86cfece0bf96f7cce1021efef9c8ed0e892ab74639173e5ed7a34fa" +checksum = "78f873475d258561b06f1c595d93308a7ed124d9977cb26b148c2084a4a3cc87" dependencies = [ "cc", "regex", - "regex-syntax 0.8.5", + "regex-syntax 0.8.6", "serde_json", "streaming-iterator", "tree-sitter-language", @@ -5669,7 +5795,7 @@ checksum = "e9d4ed7b4c18cc150a6a0a1e9ea1ecfa688791220781af6e119f9599a8502a0a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", "termcolor", ] @@ -5687,9 +5813,9 @@ checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" [[package]] name = "unicode-ident" -version = "1.0.18" +version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" [[package]] name = "unicode-linebreak" @@ -5740,9 +5866,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.4" +version = "2.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" dependencies = [ "form_urlencoded", "idna", @@ -5855,44 +5981,54 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasi" -version = "0.14.2+wasi-0.2.4" +version = "0.14.7+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" dependencies = [ - "wit-bindgen-rt", + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.100" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", + "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-backend" -version = "0.2.100" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19" dependencies = [ "bumpalo", "log", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.50" +version = "0.4.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c" dependencies = [ "cfg-if", "js-sys", @@ -5903,9 +6039,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.100" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5913,22 +6049,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.100" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.100" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1" dependencies = [ "unicode-ident", ] @@ -5948,9 +6084,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.77" +version = "0.3.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120" dependencies = [ "js-sys", "wasm-bindgen", @@ -6033,11 +6169,11 @@ checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] name = "winapi-util" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.1", ] [[package]] @@ -6053,7 +6189,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ "windows-collections", - "windows-core", + "windows-core 0.61.2", "windows-future", "windows-link 0.1.3", "windows-numerics", @@ -6065,7 +6201,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" dependencies = [ - "windows-core", + "windows-core 0.61.2", ] [[package]] @@ -6077,8 +6213,21 @@ dependencies = [ "windows-implement", "windows-interface", "windows-link 0.1.3", - "windows-result", - "windows-strings", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6844ee5416b285084d3d3fffd743b925a6c9385455f64f6d4fa3031c4c2749a9" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.0", + "windows-result 0.4.0", + "windows-strings 0.5.0", ] [[package]] @@ -6087,31 +6236,31 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ - "windows-core", + "windows-core 0.61.2", "windows-link 0.1.3", "windows-threading", ] [[package]] name = "windows-implement" -version = "0.60.0" +version = "0.60.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +checksum = "edb307e42a74fb6de9bf3a02d9712678b22399c87e6fa869d6dfcd8c1b7754e0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] name = "windows-interface" -version = "0.59.1" +version = "0.59.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +checksum = "c0abd1ddbc6964ac14db11c7213d6532ef34bd9aa042c2e5935f59d7908b46a5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -6132,7 +6281,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" dependencies = [ - "windows-core", + "windows-core 0.61.2", "windows-link 0.1.3", ] @@ -6143,8 +6292,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" dependencies = [ "windows-link 0.1.3", - "windows-result", - "windows-strings", + "windows-result 0.3.4", + "windows-strings 0.4.2", ] [[package]] @@ -6156,6 +6305,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-result" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f" +dependencies = [ + "windows-link 0.2.0", +] + [[package]] name = "windows-strings" version = "0.4.2" @@ -6165,6 +6323,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-strings" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda" +dependencies = [ + "windows-link 0.2.0", +] + [[package]] name = "windows-sys" version = "0.45.0" @@ -6198,7 +6365,16 @@ version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets 0.53.2", + "windows-targets 0.53.4", +] + +[[package]] +name = "windows-sys" +version = "0.61.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f109e41dd4a3c848907eb83d5a42ea98b3769495597450cf6d153507b166f0f" +dependencies = [ + "windows-link 0.2.0", ] [[package]] @@ -6216,21 +6392,6 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - [[package]] name = "windows-targets" version = "0.52.6" @@ -6249,10 +6410,11 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.53.2" +version = "0.53.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef" +checksum = "2d42b7b7f66d2a06854650af09cfdf8713e427a439c97ad65a6375318033ac4b" dependencies = [ + "windows-link 0.2.0", "windows_aarch64_gnullvm 0.53.0", "windows_aarch64_msvc 0.53.0", "windows_i686_gnu 0.53.0", @@ -6278,12 +6440,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -6302,12 +6458,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -6326,12 +6476,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -6362,12 +6506,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -6386,12 +6524,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -6410,12 +6542,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -6434,12 +6560,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -6454,9 +6574,9 @@ checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" [[package]] name = "winnow" -version = "0.7.12" +version = "0.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" dependencies = [ "memchr", ] @@ -6500,13 +6620,10 @@ dependencies = [ ] [[package]] -name = "wit-bindgen-rt" -version = "0.39.0" +name = "wit-bindgen" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" -dependencies = [ - "bitflags 2.9.1", -] +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "writeable" @@ -6516,20 +6633,20 @@ checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" [[package]] name = "x11rb" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d91ffca73ee7f68ce055750bf9f6eca0780b8c85eff9bc046a3b0da41755e12" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" dependencies = [ "gethostname", - "rustix 0.38.44", + "rustix 1.1.2", "x11rb-protocol", ] [[package]] name = "x11rb-protocol" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec107c4503ea0b4a98ef47356329af139c0a4f7750e621cf2973cd3385ebcb3d" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" [[package]] name = "yansi" @@ -6557,28 +6674,28 @@ checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.26" +version = "0.8.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.26" +version = "0.8.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -6598,7 +6715,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", "synstructure", ] @@ -6621,9 +6738,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.2" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428" +checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" dependencies = [ "yoke", "zerofrom", @@ -6638,7 +6755,7 @@ checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -6649,9 +6766,9 @@ checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" [[package]] name = "zune-jpeg" -version = "0.4.19" +version = "0.4.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9e525af0a6a658e031e95f14b7f889976b74a11ba0eca5a5fc9ac8a1c43a6a" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" dependencies = [ "zune-core", ] diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 7946f1d0..66d8073c 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -1,9 +1,13 @@ [workspace] members = [ + "backend-client", "ansi-escape", "app-server", "apply-patch", "arg0", + "codex-backend-openapi-models", + "cloud-tasks", + "cloud-tasks-client", "cli", "common", "core", @@ -24,6 +28,7 @@ members = [ "responses-api-proxy", "otel", "tui", + "git-apply", "utils/json-to-toml", "utils/readiness", ] @@ -59,6 +64,7 @@ codex-otel = { path = "otel" } codex-process-hardening = { path = "process-hardening" } codex-protocol = { path = "protocol" } codex-protocol-ts = { path = "protocol-ts" } +codex-responses-api-proxy = { path = "responses-api-proxy" } codex-rmcp-client = { path = "rmcp-client" } codex-tui = { path = "tui" } codex-utils-json-to-toml = { path = "utils/json-to-toml" } diff --git a/codex-rs/backend-client/Cargo.toml b/codex-rs/backend-client/Cargo.toml new file mode 100644 index 00000000..a8e2ee85 --- /dev/null +++ b/codex-rs/backend-client/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "codex-backend-client" +version = "0.0.0" +edition = "2024" +publish = false + +[lib] +path = "src/lib.rs" + +[dependencies] +anyhow = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +codex-backend-openapi-models = { path = "../codex-backend-openapi-models" } + +[dev-dependencies] +pretty_assertions = "1" diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs new file mode 100644 index 00000000..06ad00cb --- /dev/null +++ b/codex-rs/backend-client/src/client.rs @@ -0,0 +1,244 @@ +use crate::types::CodeTaskDetailsResponse; +use crate::types::PaginatedListTaskListItem; +use crate::types::TurnAttemptsSiblingTurnsResponse; +use anyhow::Result; +use reqwest::header::AUTHORIZATION; +use reqwest::header::CONTENT_TYPE; +use reqwest::header::HeaderMap; +use reqwest::header::HeaderName; +use reqwest::header::HeaderValue; +use reqwest::header::USER_AGENT; +use serde::de::DeserializeOwned; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PathStyle { + /// /api/codex/… + CodexApi, + /// /wham/… + ChatGptApi, +} + +impl PathStyle { + pub fn from_base_url(base_url: &str) -> Self { + if base_url.contains("/backend-api") { + PathStyle::ChatGptApi + } else { + PathStyle::CodexApi + } + } +} + +#[derive(Clone, Debug)] +pub struct Client { + base_url: String, + http: reqwest::Client, + bearer_token: Option, + user_agent: Option, + chatgpt_account_id: Option, + path_style: PathStyle, +} + +impl Client { + pub fn new(base_url: impl Into) -> Result { + let mut base_url = base_url.into(); + // Normalize common ChatGPT hostnames to include /backend-api so we hit the WHAM paths. + // Also trim trailing slashes for consistent URL building. + while base_url.ends_with('/') { + base_url.pop(); + } + if (base_url.starts_with("https://chatgpt.com") + || base_url.starts_with("https://chat.openai.com")) + && !base_url.contains("/backend-api") + { + base_url = format!("{base_url}/backend-api"); + } + let http = reqwest::Client::builder().build()?; + let path_style = PathStyle::from_base_url(&base_url); + Ok(Self { + base_url, + http, + bearer_token: None, + user_agent: None, + chatgpt_account_id: None, + path_style, + }) + } + + pub fn with_bearer_token(mut self, token: impl Into) -> Self { + self.bearer_token = Some(token.into()); + self + } + + pub fn with_user_agent(mut self, ua: impl Into) -> Self { + if let Ok(hv) = HeaderValue::from_str(&ua.into()) { + self.user_agent = Some(hv); + } + self + } + + pub fn with_chatgpt_account_id(mut self, account_id: impl Into) -> Self { + self.chatgpt_account_id = Some(account_id.into()); + self + } + + pub fn with_path_style(mut self, style: PathStyle) -> Self { + self.path_style = style; + self + } + + fn headers(&self) -> HeaderMap { + let mut h = HeaderMap::new(); + if let Some(ua) = &self.user_agent { + h.insert(USER_AGENT, ua.clone()); + } else { + h.insert(USER_AGENT, HeaderValue::from_static("codex-cli")); + } + if let Some(token) = &self.bearer_token { + let value = format!("Bearer {token}"); + if let Ok(hv) = HeaderValue::from_str(&value) { + h.insert(AUTHORIZATION, hv); + } + } + if let Some(acc) = &self.chatgpt_account_id + && let Ok(name) = HeaderName::from_bytes(b"ChatGPT-Account-Id") + && let Ok(hv) = HeaderValue::from_str(acc) + { + h.insert(name, hv); + } + h + } + + async fn exec_request( + &self, + req: reqwest::RequestBuilder, + method: &str, + url: &str, + ) -> Result<(String, String)> { + let res = req.send().await?; + let status = res.status(); + let ct = res + .headers() + .get(CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + let body = res.text().await.unwrap_or_default(); + if !status.is_success() { + anyhow::bail!("{method} {url} failed: {status}; content-type={ct}; body={body}"); + } + Ok((body, ct)) + } + + fn decode_json(&self, url: &str, ct: &str, body: &str) -> Result { + match serde_json::from_str::(body) { + Ok(v) => Ok(v), + Err(e) => { + anyhow::bail!("Decode error for {url}: {e}; content-type={ct}; body={body}"); + } + } + } + + pub async fn list_tasks( + &self, + limit: Option, + task_filter: Option<&str>, + environment_id: Option<&str>, + ) -> Result { + let url = match self.path_style { + PathStyle::CodexApi => format!("{}/api/codex/tasks/list", self.base_url), + PathStyle::ChatGptApi => format!("{}/wham/tasks/list", self.base_url), + }; + let req = self.http.get(&url).headers(self.headers()); + let req = if let Some(lim) = limit { + req.query(&[("limit", lim)]) + } else { + req + }; + let req = if let Some(tf) = task_filter { + req.query(&[("task_filter", tf)]) + } else { + req + }; + let req = if let Some(id) = environment_id { + req.query(&[("environment_id", id)]) + } else { + req + }; + let (body, ct) = self.exec_request(req, "GET", &url).await?; + self.decode_json::(&url, &ct, &body) + } + + pub async fn get_task_details(&self, task_id: &str) -> Result { + let (parsed, _body, _ct) = self.get_task_details_with_body(task_id).await?; + Ok(parsed) + } + + pub async fn get_task_details_with_body( + &self, + task_id: &str, + ) -> Result<(CodeTaskDetailsResponse, String, String)> { + let url = match self.path_style { + PathStyle::CodexApi => format!("{}/api/codex/tasks/{}", self.base_url, task_id), + PathStyle::ChatGptApi => format!("{}/wham/tasks/{}", self.base_url, task_id), + }; + let req = self.http.get(&url).headers(self.headers()); + let (body, ct) = self.exec_request(req, "GET", &url).await?; + let parsed: CodeTaskDetailsResponse = self.decode_json(&url, &ct, &body)?; + Ok((parsed, body, ct)) + } + + pub async fn list_sibling_turns( + &self, + task_id: &str, + turn_id: &str, + ) -> Result { + let url = match self.path_style { + PathStyle::CodexApi => format!( + "{}/api/codex/tasks/{}/turns/{}/sibling_turns", + self.base_url, task_id, turn_id + ), + PathStyle::ChatGptApi => format!( + "{}/wham/tasks/{}/turns/{}/sibling_turns", + self.base_url, task_id, turn_id + ), + }; + let req = self.http.get(&url).headers(self.headers()); + let (body, ct) = self.exec_request(req, "GET", &url).await?; + self.decode_json::(&url, &ct, &body) + } + + /// Create a new task (user turn) by POSTing to the appropriate backend path + /// based on `path_style`. Returns the created task id. + pub async fn create_task(&self, request_body: serde_json::Value) -> Result { + let url = match self.path_style { + PathStyle::CodexApi => format!("{}/api/codex/tasks", self.base_url), + PathStyle::ChatGptApi => format!("{}/wham/tasks", self.base_url), + }; + let req = self + .http + .post(&url) + .headers(self.headers()) + .header(CONTENT_TYPE, HeaderValue::from_static("application/json")) + .json(&request_body); + let (body, ct) = self.exec_request(req, "POST", &url).await?; + // Extract id from JSON: prefer `task.id`; fallback to top-level `id` when present. + match serde_json::from_str::(&body) { + Ok(v) => { + if let Some(id) = v + .get("task") + .and_then(|t| t.get("id")) + .and_then(|s| s.as_str()) + { + Ok(id.to_string()) + } else if let Some(id) = v.get("id").and_then(|s| s.as_str()) { + Ok(id.to_string()) + } else { + anyhow::bail!( + "POST {url} succeeded but no task id found; content-type={ct}; body={body}" + ); + } + } + Err(e) => anyhow::bail!("Decode error for {url}: {e}; content-type={ct}; body={body}"), + } + } +} diff --git a/codex-rs/backend-client/src/lib.rs b/codex-rs/backend-client/src/lib.rs new file mode 100644 index 00000000..29fe9f3c --- /dev/null +++ b/codex-rs/backend-client/src/lib.rs @@ -0,0 +1,9 @@ +mod client; +pub mod types; + +pub use client::Client; +pub use types::CodeTaskDetailsResponse; +pub use types::CodeTaskDetailsResponseExt; +pub use types::PaginatedListTaskListItem; +pub use types::TaskListItem; +pub use types::TurnAttemptsSiblingTurnsResponse; diff --git a/codex-rs/backend-client/src/types.rs b/codex-rs/backend-client/src/types.rs new file mode 100644 index 00000000..8e2dfb21 --- /dev/null +++ b/codex-rs/backend-client/src/types.rs @@ -0,0 +1,369 @@ +pub use codex_backend_openapi_models::models::PaginatedListTaskListItem; +pub use codex_backend_openapi_models::models::TaskListItem; + +use serde::Deserialize; +use serde::de::Deserializer; +use serde_json::Value; +use std::collections::HashMap; + +/// Hand-rolled models for the Cloud Tasks task-details response. +/// The generated OpenAPI models are pretty bad. This is a half-step +/// towards hand-rolling them. +#[derive(Clone, Debug, Deserialize)] +pub struct CodeTaskDetailsResponse { + #[serde(default)] + pub current_user_turn: Option, + #[serde(default)] + pub current_assistant_turn: Option, + #[serde(default)] + pub current_diff_task_turn: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +pub struct Turn { + #[serde(default)] + pub id: Option, + #[serde(default)] + pub attempt_placement: Option, + #[serde(default, rename = "turn_status")] + pub turn_status: Option, + #[serde(default, deserialize_with = "deserialize_vec")] + pub sibling_turn_ids: Vec, + #[serde(default, deserialize_with = "deserialize_vec")] + pub input_items: Vec, + #[serde(default, deserialize_with = "deserialize_vec")] + pub output_items: Vec, + #[serde(default)] + pub worklog: Option, + #[serde(default)] + pub error: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +pub struct TurnItem { + #[serde(rename = "type", default)] + pub kind: String, + #[serde(default)] + pub role: Option, + #[serde(default, deserialize_with = "deserialize_vec")] + pub content: Vec, + #[serde(default)] + pub diff: Option, + #[serde(default)] + pub output_diff: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(untagged)] +pub enum ContentFragment { + Structured(StructuredContent), + Text(String), +} + +#[derive(Clone, Debug, Default, Deserialize)] +pub struct StructuredContent { + #[serde(rename = "content_type", default)] + pub content_type: Option, + #[serde(default)] + pub text: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +pub struct DiffPayload { + #[serde(default)] + pub diff: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +pub struct Worklog { + #[serde(default, deserialize_with = "deserialize_vec")] + pub messages: Vec, +} + +#[derive(Clone, Debug, Default, Deserialize)] +pub struct WorklogMessage { + #[serde(default)] + pub author: Option, + #[serde(default)] + pub content: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +pub struct Author { + #[serde(default)] + pub role: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +pub struct WorklogContent { + #[serde(default)] + pub parts: Vec, +} + +#[derive(Clone, Debug, Default, Deserialize)] +pub struct TurnError { + #[serde(default)] + pub code: Option, + #[serde(default)] + pub message: Option, +} + +impl ContentFragment { + fn text(&self) -> Option<&str> { + match self { + ContentFragment::Structured(inner) => { + if inner + .content_type + .as_deref() + .map(|ct| ct.eq_ignore_ascii_case("text")) + .unwrap_or(false) + { + inner.text.as_deref().filter(|s| !s.is_empty()) + } else { + None + } + } + ContentFragment::Text(raw) => { + if raw.trim().is_empty() { + None + } else { + Some(raw.as_str()) + } + } + } + } +} + +impl TurnItem { + fn text_values(&self) -> Vec { + self.content + .iter() + .filter_map(|fragment| fragment.text().map(str::to_string)) + .collect() + } + + fn diff_text(&self) -> Option { + if self.kind == "output_diff" { + if let Some(diff) = &self.diff + && !diff.is_empty() + { + return Some(diff.clone()); + } + } else if self.kind == "pr" + && let Some(payload) = &self.output_diff + && let Some(diff) = &payload.diff + && !diff.is_empty() + { + return Some(diff.clone()); + } + None + } +} + +impl Turn { + fn unified_diff(&self) -> Option { + self.output_items.iter().find_map(TurnItem::diff_text) + } + + fn message_texts(&self) -> Vec { + let mut out: Vec = self + .output_items + .iter() + .filter(|item| item.kind == "message") + .flat_map(TurnItem::text_values) + .collect(); + + if let Some(log) = &self.worklog { + for message in &log.messages { + if message.is_assistant() { + out.extend(message.text_values()); + } + } + } + + out + } + + fn user_prompt(&self) -> Option { + let parts: Vec = self + .input_items + .iter() + .filter(|item| item.kind == "message") + .filter(|item| { + item.role + .as_deref() + .map(|r| r.eq_ignore_ascii_case("user")) + .unwrap_or(true) + }) + .flat_map(TurnItem::text_values) + .collect(); + + if parts.is_empty() { + None + } else { + Some(parts.join( + " + +", + )) + } + } + + fn error_summary(&self) -> Option { + self.error.as_ref().and_then(TurnError::summary) + } +} + +impl WorklogMessage { + fn is_assistant(&self) -> bool { + self.author + .as_ref() + .and_then(|a| a.role.as_deref()) + .map(|role| role.eq_ignore_ascii_case("assistant")) + .unwrap_or(false) + } + + fn text_values(&self) -> Vec { + self.content + .as_ref() + .map(|content| { + content + .parts + .iter() + .filter_map(|fragment| fragment.text().map(str::to_string)) + .collect() + }) + .unwrap_or_default() + } +} + +impl TurnError { + fn summary(&self) -> Option { + let code = self.code.as_deref().unwrap_or(""); + let message = self.message.as_deref().unwrap_or(""); + match (code.is_empty(), message.is_empty()) { + (true, true) => None, + (false, true) => Some(code.to_string()), + (true, false) => Some(message.to_string()), + (false, false) => Some(format!("{code}: {message}")), + } + } +} + +pub trait CodeTaskDetailsResponseExt { + /// Attempt to extract a unified diff string from the assistant or diff turn. + fn unified_diff(&self) -> Option; + /// Extract assistant text output messages (no diff) from current turns. + fn assistant_text_messages(&self) -> Vec; + /// Extract the user's prompt text from the current user turn, when present. + fn user_text_prompt(&self) -> Option; + /// Extract an assistant error message (if the turn failed and provided one). + fn assistant_error_message(&self) -> Option; +} + +impl CodeTaskDetailsResponseExt for CodeTaskDetailsResponse { + fn unified_diff(&self) -> Option { + [ + self.current_diff_task_turn.as_ref(), + self.current_assistant_turn.as_ref(), + ] + .into_iter() + .flatten() + .find_map(Turn::unified_diff) + } + + fn assistant_text_messages(&self) -> Vec { + let mut out = Vec::new(); + for turn in [ + self.current_diff_task_turn.as_ref(), + self.current_assistant_turn.as_ref(), + ] + .into_iter() + .flatten() + { + out.extend(turn.message_texts()); + } + out + } + + fn user_text_prompt(&self) -> Option { + self.current_user_turn.as_ref().and_then(Turn::user_prompt) + } + + fn assistant_error_message(&self) -> Option { + self.current_assistant_turn + .as_ref() + .and_then(Turn::error_summary) + } +} + +fn deserialize_vec<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + Option::>::deserialize(deserializer).map(|opt| opt.unwrap_or_default()) +} + +#[derive(Clone, Debug, Deserialize)] +pub struct TurnAttemptsSiblingTurnsResponse { + #[serde(default)] + pub sibling_turns: Vec>, +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + fn fixture(name: &str) -> CodeTaskDetailsResponse { + let json = match name { + "diff" => include_str!("../tests/fixtures/task_details_with_diff.json"), + "error" => include_str!("../tests/fixtures/task_details_with_error.json"), + other => panic!("unknown fixture {other}"), + }; + serde_json::from_str(json).expect("fixture should deserialize") + } + + #[test] + fn unified_diff_prefers_current_diff_task_turn() { + let details = fixture("diff"); + let diff = details.unified_diff().expect("diff present"); + assert!(diff.contains("diff --git")); + } + + #[test] + fn unified_diff_falls_back_to_pr_output_diff() { + let details = fixture("error"); + let diff = details.unified_diff().expect("diff from pr output"); + assert!(diff.contains("lib.rs")); + } + + #[test] + fn assistant_text_messages_extracts_text_content() { + let details = fixture("diff"); + let messages = details.assistant_text_messages(); + assert_eq!(messages, vec!["Assistant response".to_string()]); + } + + #[test] + fn user_text_prompt_joins_parts_with_spacing() { + let details = fixture("diff"); + let prompt = details.user_text_prompt().expect("prompt present"); + assert_eq!( + prompt, + "First line + +Second line" + ); + } + + #[test] + fn assistant_error_message_combines_code_and_message() { + let details = fixture("error"); + let msg = details + .assistant_error_message() + .expect("error should be present"); + assert_eq!(msg, "APPLY_FAILED: Patch could not be applied"); + } +} diff --git a/codex-rs/backend-client/tests/fixtures/task_details_with_diff.json b/codex-rs/backend-client/tests/fixtures/task_details_with_diff.json new file mode 100644 index 00000000..3a06b04c --- /dev/null +++ b/codex-rs/backend-client/tests/fixtures/task_details_with_diff.json @@ -0,0 +1,38 @@ +{ + "task": { + "id": "task_123", + "title": "Refactor cloud task client", + "archived": false, + "external_pull_requests": [] + }, + "current_user_turn": { + "input_items": [ + { + "type": "message", + "role": "user", + "content": [ + { "content_type": "text", "text": "First line" }, + { "content_type": "text", "text": "Second line" } + ] + } + ] + }, + "current_assistant_turn": { + "output_items": [ + { + "type": "message", + "content": [ + { "content_type": "text", "text": "Assistant response" } + ] + } + ] + }, + "current_diff_task_turn": { + "output_items": [ + { + "type": "output_diff", + "diff": "diff --git a/src/main.rs b/src/main.rs\n+fn main() { println!(\"hi\"); }\n" + } + ] + } +} diff --git a/codex-rs/backend-client/tests/fixtures/task_details_with_error.json b/codex-rs/backend-client/tests/fixtures/task_details_with_error.json new file mode 100644 index 00000000..6f6b66a7 --- /dev/null +++ b/codex-rs/backend-client/tests/fixtures/task_details_with_error.json @@ -0,0 +1,22 @@ +{ + "task": { + "id": "task_456", + "title": "Investigate failure", + "archived": false, + "external_pull_requests": [] + }, + "current_assistant_turn": { + "output_items": [ + { + "type": "pr", + "output_diff": { + "diff": "diff --git a/lib.rs b/lib.rs\n+pub fn hello() {}\n" + } + } + ], + "error": { + "code": "APPLY_FAILED", + "message": "Patch could not be applied" + } + } +} diff --git a/codex-rs/chatgpt/Cargo.toml b/codex-rs/chatgpt/Cargo.toml index 97e14d7f..0a317086 100644 --- a/codex-rs/chatgpt/Cargo.toml +++ b/codex-rs/chatgpt/Cargo.toml @@ -14,6 +14,7 @@ codex-core = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tokio = { workspace = true, features = ["full"] } +codex-git-apply = { path = "../git-apply" } [dev-dependencies] tempfile = { workspace = true } diff --git a/codex-rs/chatgpt/src/apply_command.rs b/codex-rs/chatgpt/src/apply_command.rs index 52ab205a..656197f7 100644 --- a/codex-rs/chatgpt/src/apply_command.rs +++ b/codex-rs/chatgpt/src/apply_command.rs @@ -56,46 +56,24 @@ pub async fn apply_diff_from_task( } async fn apply_diff(diff: &str, cwd: Option) -> anyhow::Result<()> { - let mut cmd = tokio::process::Command::new("git"); - if let Some(cwd) = cwd { - cmd.current_dir(cwd); - } - let toplevel_output = cmd - .args(vec!["rev-parse", "--show-toplevel"]) - .output() - .await?; - - if !toplevel_output.status.success() { - anyhow::bail!("apply must be run from a git repository."); - } - - let repo_root = String::from_utf8(toplevel_output.stdout)? - .trim() - .to_string(); - - let mut git_apply_cmd = tokio::process::Command::new("git") - .args(vec!["apply", "--3way"]) - .current_dir(&repo_root) - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .spawn()?; - - if let Some(mut stdin) = git_apply_cmd.stdin.take() { - tokio::io::AsyncWriteExt::write_all(&mut stdin, diff.as_bytes()).await?; - drop(stdin); - } - - let output = git_apply_cmd.wait_with_output().await?; - - if !output.status.success() { + let cwd = cwd.unwrap_or(std::env::current_dir().unwrap_or_else(|_| std::env::temp_dir())); + let req = codex_git_apply::ApplyGitRequest { + cwd, + diff: diff.to_string(), + revert: false, + preflight: false, + }; + let res = codex_git_apply::apply_git_patch(&req)?; + if res.exit_code != 0 { anyhow::bail!( - "Git apply failed with status {}: {}", - output.status, - String::from_utf8_lossy(&output.stderr) + "Git apply failed (applied={}, skipped={}, conflicts={})\nstdout:\n{}\nstderr:\n{}", + res.applied_paths.len(), + res.skipped_paths.len(), + res.conflicted_paths.len(), + res.stdout, + res.stderr ); } - println!("Successfully applied diff"); Ok(()) } diff --git a/codex-rs/chatgpt/src/chatgpt_client.rs b/codex-rs/chatgpt/src/chatgpt_client.rs index a4545f07..d450f7c5 100644 --- a/codex-rs/chatgpt/src/chatgpt_client.rs +++ b/codex-rs/chatgpt/src/chatgpt_client.rs @@ -44,6 +44,6 @@ pub(crate) async fn chatgpt_get_request( } else { let status = response.status(); let body = response.text().await.unwrap_or_default(); - anyhow::bail!("Request failed with status {}: {}", status, body) + anyhow::bail!("Request failed with status {status}: {body}") } } diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index c28b90a2..c62a267c 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -29,7 +29,9 @@ codex-mcp-server = { workspace = true } codex-process-hardening = { workspace = true } codex-protocol = { workspace = true } codex-protocol-ts = { workspace = true } +codex-responses-api-proxy = { workspace = true } codex-tui = { workspace = true } +codex-cloud-tasks = { path = "../cloud-tasks" } ctor = { workspace = true } owo-colors = { workspace = true } serde_json = { workspace = true } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index d0d777f5..b99577d0 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -13,8 +13,10 @@ use codex_cli::login::run_login_with_chatgpt; use codex_cli::login::run_login_with_device_code; use codex_cli::login::run_logout; use codex_cli::proto; +use codex_cloud_tasks::Cli as CloudTasksCli; use codex_common::CliConfigOverrides; use codex_exec::Cli as ExecCli; +use codex_responses_api_proxy::Args as ResponsesApiProxyArgs; use codex_tui::AppExitInfo; use codex_tui::Cli as TuiCli; use owo_colors::OwoColorize; @@ -92,6 +94,13 @@ enum Subcommand { /// Internal: generate TypeScript protocol bindings. #[clap(hide = true)] GenerateTs(GenerateTsCommand), + /// [EXPERIMENTAL] Browse tasks from Codex Cloud and apply changes locally. + #[clap(name = "cloud", alias = "cloud-tasks")] + Cloud(CloudTasksCli), + + /// Internal: run the responses API proxy. + #[clap(hide = true)] + ResponsesApiProxy(ResponsesApiProxyArgs), } #[derive(Debug, Parser)] @@ -333,6 +342,13 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() Some(Subcommand::Completion(completion_cli)) => { print_completion(completion_cli); } + Some(Subcommand::Cloud(mut cloud_cli)) => { + prepend_config_flags( + &mut cloud_cli.config_overrides, + root_config_overrides.clone(), + ); + codex_cloud_tasks::run_main(cloud_cli, codex_linux_sandbox_exe).await?; + } Some(Subcommand::Debug(debug_args)) => match debug_args.cmd { DebugCommand::Seatbelt(mut seatbelt_cli) => { prepend_config_flags( @@ -364,6 +380,10 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() ); run_apply_command(apply_cli, None).await?; } + Some(Subcommand::ResponsesApiProxy(args)) => { + tokio::task::spawn_blocking(move || codex_responses_api_proxy::run_main(args)) + .await??; + } Some(Subcommand::GenerateTs(gen_cli)) => { codex_protocol_ts::generate_ts(&gen_cli.out_dir, gen_cli.prettier.as_deref())?; } diff --git a/codex-rs/cloud-tasks-client/Cargo.toml b/codex-rs/cloud-tasks-client/Cargo.toml new file mode 100644 index 00000000..ca45b6e1 --- /dev/null +++ b/codex-rs/cloud-tasks-client/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "codex-cloud-tasks-client" +version = { workspace = true } +edition = "2024" + +[lib] +name = "codex_cloud_tasks_client" +path = "src/lib.rs" + +[lints] +workspace = true + +[features] +default = ["online"] +online = ["dep:codex-backend-client"] +mock = [] + +[dependencies] +anyhow = "1" +async-trait = "0.1" +chrono = { version = "0.4", features = ["serde"] } +diffy = "0.4.2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2.0.12" +codex-backend-client = { path = "../backend-client", optional = true } +codex-git-apply = { path = "../git-apply" } diff --git a/codex-rs/cloud-tasks-client/src/api.rs b/codex-rs/cloud-tasks-client/src/api.rs new file mode 100644 index 00000000..4bd12939 --- /dev/null +++ b/codex-rs/cloud-tasks-client/src/api.rs @@ -0,0 +1,158 @@ +use chrono::DateTime; +use chrono::Utc; +use serde::Deserialize; +use serde::Serialize; + +pub type Result = std::result::Result; + +#[derive(Debug, thiserror::Error)] +pub enum CloudTaskError { + #[error("unimplemented: {0}")] + Unimplemented(&'static str), + #[error("http error: {0}")] + Http(String), + #[error("io error: {0}")] + Io(String), + #[error("{0}")] + Msg(String), +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct TaskId(pub String); + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum TaskStatus { + Pending, + Ready, + Applied, + Error, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct TaskSummary { + pub id: TaskId, + pub title: String, + pub status: TaskStatus, + pub updated_at: DateTime, + /// Backend environment identifier (when available) + pub environment_id: Option, + /// Human-friendly environment label (when available) + pub environment_label: Option, + pub summary: DiffSummary, + /// True when the backend reports this task as a code review. + #[serde(default)] + pub is_review: bool, + /// Number of assistant attempts (best-of-N), when reported by the backend. + #[serde(default)] + pub attempt_total: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum AttemptStatus { + Pending, + InProgress, + Completed, + Failed, + Cancelled, + #[default] + Unknown, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TurnAttempt { + pub turn_id: String, + pub attempt_placement: Option, + pub created_at: Option>, + pub status: AttemptStatus, + pub diff: Option, + pub messages: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ApplyStatus { + Success, + Partial, + Error, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ApplyOutcome { + pub applied: bool, + pub status: ApplyStatus, + pub message: String, + #[serde(default)] + pub skipped_paths: Vec, + #[serde(default)] + pub conflict_paths: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CreatedTask { + pub id: TaskId, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct DiffSummary { + pub files_changed: usize, + pub lines_added: usize, + pub lines_removed: usize, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TaskText { + pub prompt: Option, + pub messages: Vec, + pub turn_id: Option, + pub sibling_turn_ids: Vec, + pub attempt_placement: Option, + pub attempt_status: AttemptStatus, +} + +impl Default for TaskText { + fn default() -> Self { + Self { + prompt: None, + messages: Vec::new(), + turn_id: None, + sibling_turn_ids: Vec::new(), + attempt_placement: None, + attempt_status: AttemptStatus::Unknown, + } + } +} + +#[async_trait::async_trait] +pub trait CloudBackend: Send + Sync { + async fn list_tasks(&self, env: Option<&str>) -> Result>; + async fn get_task_diff(&self, id: TaskId) -> Result>; + /// Return assistant output messages (no diff) when available. + async fn get_task_messages(&self, id: TaskId) -> Result>; + /// Return the creating prompt and assistant messages (when available). + async fn get_task_text(&self, id: TaskId) -> Result; + /// Return any sibling attempts (best-of-N) for the given assistant turn. + async fn list_sibling_attempts( + &self, + task: TaskId, + turn_id: String, + ) -> Result>; + /// Dry-run apply (preflight) that validates whether the patch would apply cleanly. + /// Never modifies the working tree. When `diff_override` is supplied, the provided diff is + /// used instead of re-fetching the task details so callers can apply alternate attempts. + async fn apply_task_preflight( + &self, + id: TaskId, + diff_override: Option, + ) -> Result; + async fn apply_task(&self, id: TaskId, diff_override: Option) -> Result; + async fn create_task( + &self, + env_id: &str, + prompt: &str, + git_ref: &str, + qa_mode: bool, + best_of_n: usize, + ) -> Result; +} diff --git a/codex-rs/cloud-tasks-client/src/http.rs b/codex-rs/cloud-tasks-client/src/http.rs new file mode 100644 index 00000000..912681cd --- /dev/null +++ b/codex-rs/cloud-tasks-client/src/http.rs @@ -0,0 +1,769 @@ +use crate::ApplyOutcome; +use crate::ApplyStatus; +use crate::AttemptStatus; +use crate::CloudBackend; +use crate::CloudTaskError; +use crate::DiffSummary; +use crate::Result; +use crate::TaskId; +use crate::TaskStatus; +use crate::TaskSummary; +use crate::TurnAttempt; +use crate::api::TaskText; +use chrono::DateTime; +use chrono::Utc; + +use codex_backend_client as backend; +use codex_backend_client::CodeTaskDetailsResponseExt; + +#[derive(Clone)] +pub struct HttpClient { + pub base_url: String, + backend: backend::Client, +} + +impl HttpClient { + pub fn new(base_url: impl Into) -> anyhow::Result { + let base_url = base_url.into(); + let backend = backend::Client::new(base_url.clone())?; + Ok(Self { base_url, backend }) + } + + pub fn with_bearer_token(mut self, token: impl Into) -> Self { + self.backend = self.backend.clone().with_bearer_token(token); + self + } + + pub fn with_user_agent(mut self, ua: impl Into) -> Self { + self.backend = self.backend.clone().with_user_agent(ua); + self + } + + pub fn with_chatgpt_account_id(mut self, account_id: impl Into) -> Self { + self.backend = self.backend.clone().with_chatgpt_account_id(account_id); + self + } + + fn tasks_api(&self) -> api::Tasks<'_> { + api::Tasks::new(self) + } + + fn attempts_api(&self) -> api::Attempts<'_> { + api::Attempts::new(self) + } + + fn apply_api(&self) -> api::Apply<'_> { + api::Apply::new(self) + } +} + +#[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 get_task_diff(&self, id: TaskId) -> Result> { + self.tasks_api().diff(id).await + } + + async fn get_task_messages(&self, id: TaskId) -> Result> { + self.tasks_api().messages(id).await + } + + async fn get_task_text(&self, id: TaskId) -> Result { + self.tasks_api().task_text(id).await + } + + async fn list_sibling_attempts( + &self, + task: TaskId, + turn_id: String, + ) -> Result> { + self.attempts_api().list(task, turn_id).await + } + + async fn apply_task(&self, id: TaskId, diff_override: Option) -> Result { + self.apply_api().run(id, diff_override, false).await + } + + async fn apply_task_preflight( + &self, + id: TaskId, + diff_override: Option, + ) -> Result { + self.apply_api().run(id, diff_override, true).await + } + + async fn create_task( + &self, + env_id: &str, + prompt: &str, + git_ref: &str, + qa_mode: bool, + best_of_n: usize, + ) -> Result { + self.tasks_api() + .create(env_id, prompt, git_ref, qa_mode, best_of_n) + .await + } +} + +mod api { + use super::*; + use serde_json::Value; + use std::cmp::Ordering; + use std::collections::HashMap; + + pub(crate) struct Tasks<'a> { + base_url: &'a str, + backend: &'a backend::Client, + } + + impl<'a> Tasks<'a> { + pub(crate) fn new(client: &'a HttpClient) -> Self { + Self { + base_url: &client.base_url, + backend: &client.backend, + } + } + + pub(crate) async fn list(&self, env: Option<&str>) -> Result> { + let resp = self + .backend + .list_tasks(Some(20), Some("current"), env) + .await + .map_err(|e| CloudTaskError::Http(format!("list_tasks failed: {e}")))?; + + let tasks: Vec = resp + .items + .into_iter() + .map(map_task_list_item_to_summary) + .collect(); + + append_error_log(&format!( + "http.list_tasks: env={} items={}", + env.unwrap_or(""), + tasks.len() + )); + Ok(tasks) + } + + pub(crate) async fn diff(&self, id: TaskId) -> Result> { + let (details, body, ct) = self + .details_with_body(&id.0) + .await + .map_err(|e| CloudTaskError::Http(format!("get_task_details failed: {e}")))?; + if let Some(diff) = details.unified_diff() { + return Ok(Some(diff)); + } + let _ = (body, ct); + Ok(None) + } + + pub(crate) async fn messages(&self, id: TaskId) -> Result> { + let (details, body, ct) = self + .details_with_body(&id.0) + .await + .map_err(|e| CloudTaskError::Http(format!("get_task_details failed: {e}")))?; + + let mut msgs = details.assistant_text_messages(); + if msgs.is_empty() { + msgs.extend(extract_assistant_messages_from_body(&body)); + } + if !msgs.is_empty() { + return Ok(msgs); + } + if let Some(err) = details.assistant_error_message() { + return Ok(vec![format!("Task failed: {err}")]); + } + + let url = match details_path(self.base_url, &id.0) { + Some(url) => url, + None => format!("{}/api/codex/tasks/{}", self.base_url, id.0), + }; + Err(CloudTaskError::Http(format!( + "No assistant text messages in response. GET {url}; content-type={ct}; body={body}" + ))) + } + + pub(crate) async fn task_text(&self, id: TaskId) -> Result { + let (details, body, _ct) = self + .details_with_body(&id.0) + .await + .map_err(|e| CloudTaskError::Http(format!("get_task_details failed: {e}")))?; + let prompt = details.user_text_prompt(); + let mut messages = details.assistant_text_messages(); + if messages.is_empty() { + messages.extend(extract_assistant_messages_from_body(&body)); + } + let assistant_turn = details.current_assistant_turn.as_ref(); + let turn_id = assistant_turn.and_then(|turn| turn.id.clone()); + let sibling_turn_ids = assistant_turn + .map(|turn| turn.sibling_turn_ids.clone()) + .unwrap_or_default(); + let attempt_placement = assistant_turn.and_then(|turn| turn.attempt_placement); + let attempt_status = attempt_status_from_str( + assistant_turn.and_then(|turn| turn.turn_status.as_deref()), + ); + Ok(TaskText { + prompt, + messages, + turn_id, + sibling_turn_ids, + attempt_placement, + attempt_status, + }) + } + + pub(crate) async fn create( + &self, + env_id: &str, + prompt: &str, + git_ref: &str, + qa_mode: bool, + best_of_n: usize, + ) -> Result { + let mut input_items: Vec = Vec::new(); + input_items.push(serde_json::json!({ + "type": "message", + "role": "user", + "content": [{ "content_type": "text", "text": prompt }] + })); + + if let Ok(diff) = std::env::var("CODEX_STARTING_DIFF") + && !diff.is_empty() + { + input_items.push(serde_json::json!({ + "type": "pre_apply_patch", + "output_diff": { "diff": diff } + })); + } + + let mut request_body = serde_json::json!({ + "new_task": { + "environment_id": env_id, + "branch": git_ref, + "run_environment_in_qa_mode": qa_mode, + }, + "input_items": input_items, + }); + + if best_of_n > 1 + && let Some(obj) = request_body.as_object_mut() + { + obj.insert( + "metadata".to_string(), + serde_json::json!({ "best_of_n": best_of_n }), + ); + } + + match self.backend.create_task(request_body).await { + Ok(id) => { + append_error_log(&format!( + "new_task: created id={id} env={} prompt_chars={}", + env_id, + prompt.chars().count() + )); + Ok(crate::CreatedTask { id: TaskId(id) }) + } + Err(e) => { + append_error_log(&format!( + "new_task: create failed env={} prompt_chars={}: {}", + env_id, + prompt.chars().count(), + e + )); + Err(CloudTaskError::Http(format!("create_task failed: {e}"))) + } + } + } + + async fn details_with_body( + &self, + id: &str, + ) -> anyhow::Result<(backend::CodeTaskDetailsResponse, String, String)> { + let (parsed, body, ct) = self.backend.get_task_details_with_body(id).await?; + Ok((parsed, body, ct)) + } + } + + pub(crate) struct Attempts<'a> { + backend: &'a backend::Client, + } + + impl<'a> Attempts<'a> { + pub(crate) fn new(client: &'a HttpClient) -> Self { + Self { + backend: &client.backend, + } + } + + pub(crate) async fn list(&self, task: TaskId, turn_id: String) -> Result> { + let resp = self + .backend + .list_sibling_turns(&task.0, &turn_id) + .await + .map_err(|e| CloudTaskError::Http(format!("list_sibling_turns failed: {e}")))?; + + let mut attempts: Vec = resp + .sibling_turns + .iter() + .filter_map(turn_attempt_from_map) + .collect(); + attempts.sort_by(compare_attempts); + Ok(attempts) + } + } + + pub(crate) struct Apply<'a> { + backend: &'a backend::Client, + } + + impl<'a> Apply<'a> { + pub(crate) fn new(client: &'a HttpClient) -> Self { + Self { + backend: &client.backend, + } + } + + pub(crate) async fn run( + &self, + task_id: TaskId, + diff_override: Option, + preflight: bool, + ) -> Result { + let id = task_id.0.clone(); + let diff = match diff_override { + Some(diff) => diff, + None => { + let details = self.backend.get_task_details(&id).await.map_err(|e| { + CloudTaskError::Http(format!("get_task_details failed: {e}")) + })?; + details.unified_diff().ok_or_else(|| { + CloudTaskError::Msg(format!("No diff available for task {id}")) + })? + } + }; + + if !is_unified_diff(&diff) { + let summary = summarize_patch_for_logging(&diff); + let mode = if preflight { "preflight" } else { "apply" }; + append_error_log(&format!( + "apply_error: id={id} mode={mode} format=non-unified; {summary}" + )); + return Ok(ApplyOutcome { + applied: false, + status: ApplyStatus::Error, + message: "Expected unified git diff; backend returned an incompatible format." + .to_string(), + skipped_paths: Vec::new(), + conflict_paths: Vec::new(), + }); + } + + let req = codex_git_apply::ApplyGitRequest { + cwd: std::env::current_dir().unwrap_or_else(|_| std::env::temp_dir()), + diff: diff.clone(), + revert: false, + preflight, + }; + let r = codex_git_apply::apply_git_patch(&req) + .map_err(|e| CloudTaskError::Io(format!("git apply failed to run: {e}")))?; + + let status = if r.exit_code == 0 { + ApplyStatus::Success + } else if !r.applied_paths.is_empty() || !r.conflicted_paths.is_empty() { + ApplyStatus::Partial + } else { + ApplyStatus::Error + }; + let applied = matches!(status, ApplyStatus::Success) && !preflight; + + let message = if preflight { + match status { + ApplyStatus::Success => { + format!("Preflight passed for task {id} (applies cleanly)") + } + ApplyStatus::Partial => format!( + "Preflight: patch does not fully apply for task {id} (applied={}, skipped={}, conflicts={})", + r.applied_paths.len(), + r.skipped_paths.len(), + r.conflicted_paths.len() + ), + ApplyStatus::Error => format!( + "Preflight failed for task {id} (applied={}, skipped={}, conflicts={})", + r.applied_paths.len(), + r.skipped_paths.len(), + r.conflicted_paths.len() + ), + } + } else { + match status { + ApplyStatus::Success => format!( + "Applied task {id} locally ({} files)", + r.applied_paths.len() + ), + ApplyStatus::Partial => format!( + "Apply partially succeeded for task {id} (applied={}, skipped={}, conflicts={})", + r.applied_paths.len(), + r.skipped_paths.len(), + r.conflicted_paths.len() + ), + ApplyStatus::Error => format!( + "Apply failed for task {id} (applied={}, skipped={}, conflicts={})", + r.applied_paths.len(), + r.skipped_paths.len(), + r.conflicted_paths.len() + ), + } + }; + + if matches!(status, ApplyStatus::Partial | ApplyStatus::Error) + || (preflight && !matches!(status, ApplyStatus::Success)) + { + let mut log = String::new(); + let summary = summarize_patch_for_logging(&diff); + let mode = if preflight { "preflight" } else { "apply" }; + use std::fmt::Write as _; + let _ = writeln!( + &mut log, + "apply_result: mode={} id={} status={:?} applied={} skipped={} conflicts={} cmd={}", + mode, + id, + status, + r.applied_paths.len(), + r.skipped_paths.len(), + r.conflicted_paths.len(), + r.cmd_for_log + ); + let _ = writeln!( + &mut log, + "stdout_tail=\n{}\nstderr_tail=\n{}", + tail(&r.stdout, 2000), + tail(&r.stderr, 2000) + ); + let _ = writeln!(&mut log, "{summary}"); + let _ = writeln!( + &mut log, + "----- PATCH BEGIN -----\n{diff}\n----- PATCH END -----" + ); + append_error_log(&log); + } + + Ok(ApplyOutcome { + applied, + status, + message, + skipped_paths: r.skipped_paths, + conflict_paths: r.conflicted_paths, + }) + } + } + + fn details_path(base_url: &str, id: &str) -> Option { + if base_url.contains("/backend-api") { + Some(format!("{base_url}/wham/tasks/{id}")) + } else if base_url.contains("/api/codex") { + Some(format!("{base_url}/tasks/{id}")) + } else { + None + } + } + + fn extract_assistant_messages_from_body(body: &str) -> Vec { + let mut msgs = Vec::new(); + if let Ok(full) = serde_json::from_str::(body) + && let Some(arr) = full + .get("current_assistant_turn") + .and_then(|v| v.get("worklog")) + .and_then(|v| v.get("messages")) + .and_then(|v| v.as_array()) + { + for m in arr { + let is_assistant = m + .get("author") + .and_then(|a| a.get("role")) + .and_then(|r| r.as_str()) + == Some("assistant"); + if !is_assistant { + continue; + } + if let Some(parts) = m + .get("content") + .and_then(|c| c.get("parts")) + .and_then(|p| p.as_array()) + { + for p in parts { + if let Some(s) = p.as_str() { + if !s.is_empty() { + msgs.push(s.to_string()); + } + continue; + } + if let Some(obj) = p.as_object() + && obj.get("content_type").and_then(|t| t.as_str()) == Some("text") + && let Some(txt) = obj.get("text").and_then(|t| t.as_str()) + { + msgs.push(txt.to_string()); + } + } + } + } + } + msgs + } + + fn turn_attempt_from_map(turn: &HashMap) -> Option { + let turn_id = turn.get("id").and_then(Value::as_str)?.to_string(); + let attempt_placement = turn.get("attempt_placement").and_then(Value::as_i64); + let created_at = parse_timestamp_value(turn.get("created_at")); + let status = attempt_status_from_str(turn.get("turn_status").and_then(Value::as_str)); + let diff = extract_diff_from_turn(turn); + let messages = extract_assistant_messages_from_turn(turn); + Some(TurnAttempt { + turn_id, + attempt_placement, + created_at, + status, + diff, + messages, + }) + } + + fn compare_attempts(a: &TurnAttempt, b: &TurnAttempt) -> Ordering { + match (a.attempt_placement, b.attempt_placement) { + (Some(lhs), Some(rhs)) => lhs.cmp(&rhs), + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, + (None, None) => match (a.created_at, b.created_at) { + (Some(lhs), Some(rhs)) => lhs.cmp(&rhs), + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, + (None, None) => a.turn_id.cmp(&b.turn_id), + }, + } + } + + fn extract_diff_from_turn(turn: &HashMap) -> Option { + let items = turn.get("output_items").and_then(Value::as_array)?; + for item in items { + match item.get("type").and_then(Value::as_str) { + Some("output_diff") => { + if let Some(diff) = item.get("diff").and_then(Value::as_str) + && !diff.is_empty() + { + return Some(diff.to_string()); + } + } + Some("pr") => { + if let Some(diff) = item + .get("output_diff") + .and_then(Value::as_object) + .and_then(|od| od.get("diff")) + .and_then(Value::as_str) + && !diff.is_empty() + { + return Some(diff.to_string()); + } + } + _ => {} + } + } + None + } + + fn extract_assistant_messages_from_turn(turn: &HashMap) -> Vec { + let mut msgs = Vec::new(); + if let Some(items) = turn.get("output_items").and_then(Value::as_array) { + for item in items { + if item.get("type").and_then(Value::as_str) != Some("message") { + continue; + } + if let Some(content) = item.get("content").and_then(Value::as_array) { + for part in content { + if part.get("content_type").and_then(Value::as_str) == Some("text") + && let Some(txt) = part.get("text").and_then(Value::as_str) + && !txt.is_empty() + { + msgs.push(txt.to_string()); + } + } + } + } + } + msgs + } + + fn attempt_status_from_str(raw: Option<&str>) -> AttemptStatus { + match raw.unwrap_or_default() { + "failed" => AttemptStatus::Failed, + "completed" => AttemptStatus::Completed, + "in_progress" => AttemptStatus::InProgress, + "pending" => AttemptStatus::Pending, + _ => AttemptStatus::Pending, + } + } + + fn parse_timestamp_value(v: Option<&Value>) -> Option> { + let ts = v?.as_f64()?; + let secs = ts as i64; + let nanos = ((ts - secs as f64) * 1_000_000_000.0) as u32; + Some(DateTime::::from( + std::time::UNIX_EPOCH + std::time::Duration::new(secs.max(0) as u64, nanos), + )) + } + + fn map_task_list_item_to_summary(src: backend::TaskListItem) -> TaskSummary { + let status_display = src.task_status_display.as_ref(); + TaskSummary { + id: TaskId(src.id), + title: src.title, + status: map_status(status_display), + updated_at: parse_updated_at(src.updated_at.as_ref()), + environment_id: None, + environment_label: env_label_from_status_display(status_display), + summary: diff_summary_from_status_display(status_display), + is_review: src + .pull_requests + .as_ref() + .is_some_and(|prs| !prs.is_empty()), + attempt_total: attempt_total_from_status_display(status_display), + } + } + + fn map_status(v: Option<&HashMap>) -> TaskStatus { + if let Some(val) = v { + if let Some(turn) = val + .get("latest_turn_status_display") + .and_then(Value::as_object) + && let Some(s) = turn.get("turn_status").and_then(Value::as_str) + { + return match s { + "failed" => TaskStatus::Error, + "completed" => TaskStatus::Ready, + "in_progress" => TaskStatus::Pending, + "pending" => TaskStatus::Pending, + "cancelled" => TaskStatus::Error, + _ => TaskStatus::Pending, + }; + } + if let Some(state) = val.get("state").and_then(Value::as_str) { + return match state { + "pending" => TaskStatus::Pending, + "ready" => TaskStatus::Ready, + "applied" => TaskStatus::Applied, + "error" => TaskStatus::Error, + _ => TaskStatus::Pending, + }; + } + } + TaskStatus::Pending + } + + fn parse_updated_at(ts: Option<&f64>) -> DateTime { + if let Some(v) = ts { + let secs = *v as i64; + let nanos = ((*v - secs as f64) * 1_000_000_000.0) as u32; + return DateTime::::from( + std::time::UNIX_EPOCH + std::time::Duration::new(secs.max(0) as u64, nanos), + ); + } + Utc::now() + } + + fn env_label_from_status_display(v: Option<&HashMap>) -> Option { + let map = v?; + map.get("environment_label") + .and_then(Value::as_str) + .map(str::to_string) + } + + fn diff_summary_from_status_display(v: Option<&HashMap>) -> DiffSummary { + let mut out = DiffSummary::default(); + let Some(map) = v else { return out }; + let latest = map + .get("latest_turn_status_display") + .and_then(Value::as_object); + let Some(latest) = latest else { return out }; + if let Some(ds) = latest.get("diff_stats").and_then(Value::as_object) { + if let Some(n) = ds.get("files_modified").and_then(Value::as_i64) { + out.files_changed = n.max(0) as usize; + } + if let Some(n) = ds.get("lines_added").and_then(Value::as_i64) { + out.lines_added = n.max(0) as usize; + } + if let Some(n) = ds.get("lines_removed").and_then(Value::as_i64) { + out.lines_removed = n.max(0) as usize; + } + } + out + } + + fn attempt_total_from_status_display(v: Option<&HashMap>) -> Option { + let map = v?; + let latest = map + .get("latest_turn_status_display") + .and_then(Value::as_object)?; + let siblings = latest.get("sibling_turn_ids").and_then(Value::as_array)?; + Some(siblings.len().saturating_add(1)) + } + + fn is_unified_diff(diff: &str) -> bool { + let t = diff.trim_start(); + if t.starts_with("diff --git ") { + return true; + } + let has_dash_headers = diff.contains("\n--- ") && diff.contains("\n+++ "); + let has_hunk = diff.contains("\n@@ ") || diff.starts_with("@@ "); + has_dash_headers && has_hunk + } + + fn tail(s: &str, max: usize) -> String { + if s.len() <= max { + s.to_string() + } else { + s[s.len() - max..].to_string() + } + } + + fn summarize_patch_for_logging(patch: &str) -> String { + let trimmed = patch.trim_start(); + let kind = if trimmed.starts_with("*** Begin Patch") { + "codex-patch" + } else if trimmed.starts_with("diff --git ") || trimmed.contains("\n*** End Patch\n") { + "git-diff" + } else if trimmed.starts_with("@@ ") || trimmed.contains("\n@@ ") { + "unified-diff" + } else { + "unknown" + }; + let lines = patch.lines().count(); + let chars = patch.len(); + let cwd = std::env::current_dir() + .ok() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| "".to_string()); + let head: String = patch.lines().take(20).collect::>().join("\n"); + let head_trunc = if head.len() > 800 { + format!("{}…", &head[..800]) + } else { + head + }; + format!( + "patch_summary: kind={kind} lines={lines} chars={chars} cwd={cwd} ; head=\n{head_trunc}" + ) + } +} + +fn append_error_log(message: &str) { + let ts = Utc::now().to_rfc3339(); + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open("error.log") + { + use std::io::Write as _; + let _ = writeln!(f, "[{ts}] {message}"); + } +} diff --git a/codex-rs/cloud-tasks-client/src/lib.rs b/codex-rs/cloud-tasks-client/src/lib.rs new file mode 100644 index 00000000..1c5a01e2 --- /dev/null +++ b/codex-rs/cloud-tasks-client/src/lib.rs @@ -0,0 +1,29 @@ +mod api; + +pub use api::ApplyOutcome; +pub use api::ApplyStatus; +pub use api::AttemptStatus; +pub use api::CloudBackend; +pub use api::CloudTaskError; +pub use api::CreatedTask; +pub use api::DiffSummary; +pub use api::Result; +pub use api::TaskId; +pub use api::TaskStatus; +pub use api::TaskSummary; +pub use api::TaskText; +pub use api::TurnAttempt; + +#[cfg(feature = "mock")] +mod mock; + +#[cfg(feature = "online")] +mod http; + +#[cfg(feature = "mock")] +pub use mock::MockClient; + +#[cfg(feature = "online")] +pub use http::HttpClient; + +// Reusable apply engine now lives in the shared crate `codex-git-apply`. diff --git a/codex-rs/cloud-tasks-client/src/mock.rs b/codex-rs/cloud-tasks-client/src/mock.rs new file mode 100644 index 00000000..97bc5520 --- /dev/null +++ b/codex-rs/cloud-tasks-client/src/mock.rs @@ -0,0 +1,180 @@ +use crate::ApplyOutcome; +use crate::AttemptStatus; +use crate::CloudBackend; +use crate::DiffSummary; +use crate::Result; +use crate::TaskId; +use crate::TaskStatus; +use crate::TaskSummary; +use crate::TurnAttempt; +use crate::api::TaskText; +use chrono::Utc; + +#[derive(Clone, Default)] +pub struct MockClient; + +#[async_trait::async_trait] +impl CloudBackend for MockClient { + async fn list_tasks(&self, _env: 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)], + Some("env-B") => vec![ + ("T-3000", "B: One", TaskStatus::Ready), + ("T-3001", "B: Two", TaskStatus::Pending), + ], + _ => vec![ + ("T-1000", "Update README formatting", TaskStatus::Ready), + ("T-1001", "Fix clippy warnings in core", TaskStatus::Pending), + ("T-1002", "Add contributing guide", TaskStatus::Ready), + ], + }; + let environment_id = _env.map(str::to_string); + let environment_label = match _env { + Some("env-A") => Some("Env A".to_string()), + Some("env-B") => Some("Env B".to_string()), + Some(other) => Some(other.to_string()), + None => Some("Global".to_string()), + }; + let mut out = Vec::new(); + for (id_str, title, status) in rows { + let id = TaskId(id_str.to_string()); + let diff = mock_diff_for(&id); + let (a, d) = count_from_unified(&diff); + out.push(TaskSummary { + id, + title: title.to_string(), + status, + updated_at: Utc::now(), + environment_id: environment_id.clone(), + environment_label: environment_label.clone(), + summary: DiffSummary { + files_changed: 1, + lines_added: a, + lines_removed: d, + }, + is_review: false, + attempt_total: Some(if id_str == "T-1000" { 2 } else { 1 }), + }); + } + Ok(out) + } + + async fn get_task_diff(&self, id: TaskId) -> Result> { + Ok(Some(mock_diff_for(&id))) + } + + async fn get_task_messages(&self, _id: TaskId) -> Result> { + Ok(vec![ + "Mock assistant output: this task contains no diff.".to_string(), + ]) + } + + async fn get_task_text(&self, _id: TaskId) -> Result { + Ok(TaskText { + prompt: Some("Why is there no diff?".to_string()), + messages: vec!["Mock assistant output: this task contains no diff.".to_string()], + turn_id: Some("mock-turn".to_string()), + sibling_turn_ids: Vec::new(), + attempt_placement: Some(0), + attempt_status: AttemptStatus::Completed, + }) + } + + async fn apply_task(&self, id: TaskId, _diff_override: Option) -> Result { + Ok(ApplyOutcome { + applied: true, + status: crate::ApplyStatus::Success, + message: format!("Applied task {} locally (mock)", id.0), + skipped_paths: Vec::new(), + conflict_paths: Vec::new(), + }) + } + + async fn apply_task_preflight( + &self, + id: TaskId, + _diff_override: Option, + ) -> Result { + Ok(ApplyOutcome { + applied: false, + status: crate::ApplyStatus::Success, + message: format!("Preflight passed for task {} (mock)", id.0), + skipped_paths: Vec::new(), + conflict_paths: Vec::new(), + }) + } + + async fn list_sibling_attempts( + &self, + task: TaskId, + _turn_id: String, + ) -> Result> { + if task.0 == "T-1000" { + return Ok(vec![TurnAttempt { + turn_id: "T-1000-attempt-2".to_string(), + attempt_placement: Some(1), + created_at: Some(Utc::now()), + status: AttemptStatus::Completed, + diff: Some(mock_diff_for(&task)), + messages: vec!["Mock alternate attempt".to_string()], + }]); + } + Ok(Vec::new()) + } + + async fn create_task( + &self, + env_id: &str, + prompt: &str, + git_ref: &str, + qa_mode: bool, + best_of_n: usize, + ) -> Result { + let _ = (env_id, prompt, git_ref, qa_mode, best_of_n); + let id = format!("task_local_{}", chrono::Utc::now().timestamp_millis()); + Ok(crate::CreatedTask { id: TaskId(id) }) + } +} + +fn mock_diff_for(id: &TaskId) -> String { + match id.0.as_str() { + "T-1000" => { + "diff --git a/README.md b/README.md\nindex 000000..111111 100644\n--- a/README.md\n+++ b/README.md\n@@ -1,2 +1,3 @@\n Intro\n-Hello\n+Hello, world!\n+Task: T-1000\n".to_string() + } + "T-1001" => { + "diff --git a/core/src/lib.rs b/core/src/lib.rs\nindex 000000..111111 100644\n--- a/core/src/lib.rs\n+++ b/core/src/lib.rs\n@@ -1,2 +1,1 @@\n-use foo;\n use bar;\n".to_string() + } + _ => { + "diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md\nindex 000000..111111 100644\n--- /dev/null\n+++ b/CONTRIBUTING.md\n@@ -0,0 +1,3 @@\n+## Contributing\n+Please open PRs.\n+Thanks!\n".to_string() + } + } +} + +fn count_from_unified(diff: &str) -> (usize, usize) { + if let Ok(patch) = diffy::Patch::from_str(diff) { + patch + .hunks() + .iter() + .flat_map(diffy::Hunk::lines) + .fold((0, 0), |(a, d), l| match l { + diffy::Line::Insert(_) => (a + 1, d), + diffy::Line::Delete(_) => (a, d + 1), + _ => (a, d), + }) + } else { + let mut a = 0; + let mut d = 0; + for l in diff.lines() { + if l.starts_with("+++") || l.starts_with("---") || l.starts_with("@@") { + continue; + } + match l.as_bytes().first() { + Some(b'+') => a += 1, + Some(b'-') => d += 1, + _ => {} + } + } + (a, d) + } +} diff --git a/codex-rs/cloud-tasks/Cargo.toml b/codex-rs/cloud-tasks/Cargo.toml new file mode 100644 index 00000000..d0cee3ff --- /dev/null +++ b/codex-rs/cloud-tasks/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "codex-cloud-tasks" +version = { workspace = true } +edition = "2024" + +[lib] +name = "codex_cloud_tasks" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +anyhow = "1" +clap = { version = "4", features = ["derive"] } +codex-common = { path = "../common", features = ["cli"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +tracing = { version = "0.1.41", features = ["log"] } +tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } +codex-cloud-tasks-client = { path = "../cloud-tasks-client", features = ["mock", "online"] } +ratatui = { version = "0.29.0" } +crossterm = { version = "0.28.1", features = ["event-stream"] } +tokio-stream = "0.1.17" +chrono = { version = "0.4", features = ["serde"] } +codex-login = { path = "../login" } +codex-core = { path = "../core" } +throbber-widgets-tui = "0.8.0" +base64 = "0.22" +serde_json = "1" +reqwest = { version = "0.12", features = ["json"] } +serde = { version = "1", features = ["derive"] } +unicode-width = "0.1" +codex-tui = { path = "../tui" } + +[dev-dependencies] +async-trait = "0.1" diff --git a/codex-rs/cloud-tasks/src/app.rs b/codex-rs/cloud-tasks/src/app.rs new file mode 100644 index 00000000..adf17419 --- /dev/null +++ b/codex-rs/cloud-tasks/src/app.rs @@ -0,0 +1,482 @@ +use std::time::Duration; + +// Environment filter data models for the TUI +#[derive(Clone, Debug, Default)] +pub struct EnvironmentRow { + pub id: String, + pub label: Option, + pub is_pinned: bool, + pub repo_hints: Option, // e.g., "openai/codex" +} + +#[derive(Clone, Debug, Default)] +pub struct EnvModalState { + pub query: String, + pub selected: usize, +} + +#[derive(Clone, Debug, Default)] +pub struct BestOfModalState { + pub selected: usize, +} + +#[derive(Clone, Debug, Copy, PartialEq, Eq)] +pub enum ApplyResultLevel { + Success, + Partial, + Error, +} + +#[derive(Clone, Debug)] +pub struct ApplyModalState { + pub task_id: TaskId, + pub title: String, + pub result_message: Option, + pub result_level: Option, + pub skipped_paths: Vec, + pub conflict_paths: Vec, + pub diff_override: Option, +} + +use crate::scrollable_diff::ScrollableDiff; +use codex_cloud_tasks_client::CloudBackend; +use codex_cloud_tasks_client::TaskId; +use codex_cloud_tasks_client::TaskSummary; +use throbber_widgets_tui::ThrobberState; + +#[derive(Default)] +pub struct App { + pub tasks: Vec, + pub selected: usize, + pub status: String, + pub diff_overlay: Option, + pub throbber: ThrobberState, + pub refresh_inflight: bool, + pub details_inflight: bool, + // Environment filter state + pub env_filter: Option, + pub env_modal: Option, + pub apply_modal: Option, + pub best_of_modal: Option, + pub environments: Vec, + pub env_last_loaded: Option, + pub env_loading: bool, + pub env_error: Option, + // New Task page + pub new_task: Option, + pub best_of_n: usize, + // Apply preflight spinner state + pub apply_preflight_inflight: bool, + // Apply action spinner state + pub apply_inflight: bool, + // Background enrichment coordination + pub list_generation: u64, + pub in_flight: std::collections::HashSet, + // Background enrichment caches were planned; currently unused. +} + +impl App { + pub fn new() -> Self { + Self { + tasks: Vec::new(), + selected: 0, + status: "Press r to refresh".to_string(), + diff_overlay: None, + throbber: ThrobberState::default(), + refresh_inflight: false, + details_inflight: false, + env_filter: None, + env_modal: None, + apply_modal: None, + best_of_modal: None, + environments: Vec::new(), + env_last_loaded: None, + env_loading: false, + env_error: None, + new_task: None, + best_of_n: 1, + apply_preflight_inflight: false, + apply_inflight: false, + list_generation: 0, + in_flight: std::collections::HashSet::new(), + } + } + + pub fn next(&mut self) { + if self.tasks.is_empty() { + return; + } + self.selected = (self.selected + 1).min(self.tasks.len().saturating_sub(1)); + } + + pub fn prev(&mut self) { + if self.tasks.is_empty() { + return; + } + if self.selected > 0 { + self.selected -= 1; + } + } +} + +pub async fn load_tasks( + backend: &dyn CloudBackend, + 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??; + // Hide review-only tasks from the main list. + let filtered: Vec = tasks.into_iter().filter(|t| !t.is_review).collect(); + Ok(filtered) +} + +pub struct DiffOverlay { + pub title: String, + pub task_id: TaskId, + pub sd: ScrollableDiff, + pub base_can_apply: bool, + pub diff_lines: Vec, + pub text_lines: Vec, + pub prompt: Option, + pub attempts: Vec, + pub selected_attempt: usize, + pub current_view: DetailView, + pub base_turn_id: Option, + pub sibling_turn_ids: Vec, + pub attempt_total_hint: Option, +} + +#[derive(Clone, Debug, Default)] +pub struct AttemptView { + pub turn_id: Option, + pub status: codex_cloud_tasks_client::AttemptStatus, + pub attempt_placement: Option, + pub diff_lines: Vec, + pub text_lines: Vec, + pub prompt: Option, + pub diff_raw: Option, +} + +impl AttemptView { + pub fn has_diff(&self) -> bool { + !self.diff_lines.is_empty() + } + + pub fn has_text(&self) -> bool { + !self.text_lines.is_empty() || self.prompt.is_some() + } +} + +impl DiffOverlay { + pub fn new(task_id: TaskId, title: String, attempt_total_hint: Option) -> Self { + let mut sd = ScrollableDiff::new(); + sd.set_content(Vec::new()); + Self { + title, + task_id, + sd, + base_can_apply: false, + diff_lines: Vec::new(), + text_lines: Vec::new(), + prompt: None, + attempts: vec![AttemptView::default()], + selected_attempt: 0, + current_view: DetailView::Prompt, + base_turn_id: None, + sibling_turn_ids: Vec::new(), + attempt_total_hint, + } + } + + pub fn current_attempt(&self) -> Option<&AttemptView> { + self.attempts.get(self.selected_attempt) + } + + pub fn base_attempt_mut(&mut self) -> &mut AttemptView { + if self.attempts.is_empty() { + self.attempts.push(AttemptView::default()); + } + &mut self.attempts[0] + } + + pub fn set_view(&mut self, view: DetailView) { + self.current_view = view; + self.apply_selection_to_fields(); + } + + pub fn expected_attempts(&self) -> Option { + self.attempt_total_hint.or({ + if self.attempts.is_empty() { + None + } else { + Some(self.attempts.len()) + } + }) + } + + pub fn attempt_count(&self) -> usize { + self.attempts.len() + } + + pub fn attempt_display_total(&self) -> usize { + self.expected_attempts() + .unwrap_or_else(|| self.attempts.len().max(1)) + } + + pub fn step_attempt(&mut self, delta: isize) -> bool { + let total = self.attempts.len(); + if total <= 1 { + return false; + } + let total_isize = total as isize; + let current = self.selected_attempt as isize; + let mut next = current + delta; + next = ((next % total_isize) + total_isize) % total_isize; + let next = next as usize; + self.selected_attempt = next; + self.apply_selection_to_fields(); + true + } + + pub fn current_can_apply(&self) -> bool { + matches!(self.current_view, DetailView::Diff) + && self + .current_attempt() + .and_then(|attempt| attempt.diff_raw.as_ref()) + .map(|diff| !diff.is_empty()) + .unwrap_or(false) + } + + pub fn apply_selection_to_fields(&mut self) { + let (diff_lines, text_lines, prompt) = if let Some(attempt) = self.current_attempt() { + ( + attempt.diff_lines.clone(), + attempt.text_lines.clone(), + attempt.prompt.clone(), + ) + } else { + self.diff_lines.clear(); + self.text_lines.clear(); + self.prompt = None; + self.sd.set_content(vec!["".to_string()]); + return; + }; + + self.diff_lines = diff_lines.clone(); + self.text_lines = text_lines.clone(); + self.prompt = prompt; + + match self.current_view { + DetailView::Diff => { + if diff_lines.is_empty() { + self.sd.set_content(vec!["".to_string()]); + } else { + self.sd.set_content(diff_lines); + } + } + DetailView::Prompt => { + if text_lines.is_empty() { + self.sd.set_content(vec!["".to_string()]); + } else { + self.sd.set_content(text_lines); + } + } + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DetailView { + Diff, + Prompt, +} + +/// Internal app events delivered from background tasks. +/// These let the UI event loop remain responsive and keep the spinner animating. +#[derive(Debug)] +pub enum AppEvent { + TasksLoaded { + env: Option, + result: anyhow::Result>, + }, + // Background diff summary events were planned; removed for now to keep code minimal. + /// Autodetection of a likely environment id finished + EnvironmentAutodetected(anyhow::Result), + /// Background completion of environment list fetch + EnvironmentsLoaded(anyhow::Result>), + DetailsDiffLoaded { + id: TaskId, + title: String, + diff: String, + }, + DetailsMessagesLoaded { + id: TaskId, + title: String, + messages: Vec, + prompt: Option, + turn_id: Option, + sibling_turn_ids: Vec, + attempt_placement: Option, + attempt_status: codex_cloud_tasks_client::AttemptStatus, + }, + DetailsFailed { + id: TaskId, + title: String, + error: String, + }, + AttemptsLoaded { + id: TaskId, + attempts: Vec, + }, + /// Background completion of new task submission + NewTaskSubmitted(Result), + /// Background completion of apply preflight when opening modal or on demand + ApplyPreflightFinished { + id: TaskId, + title: String, + message: String, + level: ApplyResultLevel, + skipped: Vec, + conflicts: Vec, + }, + /// Background completion of apply action (actual patch application) + ApplyFinished { + id: TaskId, + result: std::result::Result, + }, +} + +// Convenience aliases; currently unused. +#[cfg(test)] +mod tests { + use super::*; + use chrono::Utc; + + struct FakeBackend { + // maps env key to titles + by_env: std::collections::HashMap, Vec<&'static str>>, + } + + #[async_trait::async_trait] + impl codex_cloud_tasks_client::CloudBackend for FakeBackend { + async fn list_tasks( + &self, + env: Option<&str>, + ) -> codex_cloud_tasks_client::Result> { + let key = env.map(str::to_string); + let titles = self + .by_env + .get(&key) + .cloned() + .unwrap_or_else(|| vec!["default-a", "default-b"]); + let mut out = Vec::new(); + for (i, t) in titles.into_iter().enumerate() { + out.push(TaskSummary { + id: TaskId(format!("T-{i}")), + title: t.to_string(), + status: codex_cloud_tasks_client::TaskStatus::Ready, + updated_at: Utc::now(), + environment_id: env.map(str::to_string), + environment_label: None, + summary: codex_cloud_tasks_client::DiffSummary::default(), + is_review: false, + attempt_total: Some(1), + }); + } + Ok(out) + } + + async fn get_task_diff( + &self, + _id: TaskId, + ) -> codex_cloud_tasks_client::Result> { + Err(codex_cloud_tasks_client::CloudTaskError::Unimplemented( + "not used in test", + )) + } + + async fn get_task_messages( + &self, + _id: TaskId, + ) -> codex_cloud_tasks_client::Result> { + Ok(vec![]) + } + async fn get_task_text( + &self, + _id: TaskId, + ) -> codex_cloud_tasks_client::Result { + Ok(codex_cloud_tasks_client::TaskText { + prompt: Some("Example prompt".to_string()), + messages: Vec::new(), + turn_id: Some("fake-turn".to_string()), + sibling_turn_ids: Vec::new(), + attempt_placement: Some(0), + attempt_status: codex_cloud_tasks_client::AttemptStatus::Completed, + }) + } + + async fn list_sibling_attempts( + &self, + _task: TaskId, + _turn_id: String, + ) -> codex_cloud_tasks_client::Result> { + Ok(Vec::new()) + } + + async fn apply_task( + &self, + _id: TaskId, + _diff_override: Option, + ) -> codex_cloud_tasks_client::Result { + Err(codex_cloud_tasks_client::CloudTaskError::Unimplemented( + "not used in test", + )) + } + + async fn apply_task_preflight( + &self, + _id: TaskId, + _diff_override: Option, + ) -> codex_cloud_tasks_client::Result { + Err(codex_cloud_tasks_client::CloudTaskError::Unimplemented( + "not used in test", + )) + } + + async fn create_task( + &self, + _env_id: &str, + _prompt: &str, + _git_ref: &str, + _qa_mode: bool, + _best_of_n: usize, + ) -> codex_cloud_tasks_client::Result { + Err(codex_cloud_tasks_client::CloudTaskError::Unimplemented( + "not used in test", + )) + } + } + + #[tokio::test] + async fn load_tasks_uses_env_parameter() { + // Arrange: env-specific task titles + let mut by_env = std::collections::HashMap::new(); + by_env.insert(None, vec!["root-1", "root-2"]); + by_env.insert(Some("env-A".to_string()), vec!["A-1"]); + by_env.insert(Some("env-B".to_string()), vec!["B-1", "B-2", "B-3"]); + let backend = FakeBackend { by_env }; + + // Act + Assert + let root = load_tasks(&backend, None).await.unwrap(); + assert_eq!(root.len(), 2); + assert_eq!(root[0].title, "root-1"); + + let a = load_tasks(&backend, Some("env-A")).await.unwrap(); + assert_eq!(a.len(), 1); + assert_eq!(a[0].title, "A-1"); + + let b = load_tasks(&backend, Some("env-B")).await.unwrap(); + assert_eq!(b.len(), 3); + assert_eq!(b[2].title, "B-3"); + } +} diff --git a/codex-rs/cloud-tasks/src/cli.rs b/codex-rs/cloud-tasks/src/cli.rs new file mode 100644 index 00000000..81125aeb --- /dev/null +++ b/codex-rs/cloud-tasks/src/cli.rs @@ -0,0 +1,9 @@ +use clap::Parser; +use codex_common::CliConfigOverrides; + +#[derive(Parser, Debug, Default)] +#[command(version)] +pub struct Cli { + #[clap(skip)] + pub config_overrides: CliConfigOverrides, +} diff --git a/codex-rs/cloud-tasks/src/env_detect.rs b/codex-rs/cloud-tasks/src/env_detect.rs new file mode 100644 index 00000000..e7e8fb6b --- /dev/null +++ b/codex-rs/cloud-tasks/src/env_detect.rs @@ -0,0 +1,361 @@ +use reqwest::header::CONTENT_TYPE; +use reqwest::header::HeaderMap; +use std::collections::HashMap; +use tracing::info; +use tracing::warn; + +#[derive(Debug, Clone, serde::Deserialize)] +struct CodeEnvironment { + id: String, + #[serde(default)] + label: Option, + #[serde(default)] + is_pinned: Option, + #[serde(default)] + task_count: Option, +} + +#[derive(Debug, Clone)] +pub struct AutodetectSelection { + pub id: String, + pub label: Option, +} + +pub async fn autodetect_environment_id( + base_url: &str, + headers: &HeaderMap, + desired_label: Option, +) -> anyhow::Result { + // 1) Try repo-specific environments based on local git origins (GitHub only, like VSCode) + let origins = get_git_origins(); + crate::append_error_log(format!("env: git origins: {origins:?}")); + let mut by_repo_envs: Vec = Vec::new(); + for origin in &origins { + if let Some((owner, repo)) = parse_owner_repo(origin) { + let url = if base_url.contains("/backend-api") { + format!( + "{}/wham/environments/by-repo/{}/{}/{}", + base_url, "github", owner, repo + ) + } else { + format!( + "{}/api/codex/environments/by-repo/{}/{}/{}", + base_url, "github", owner, repo + ) + }; + crate::append_error_log(format!("env: GET {url}")); + match get_json::>(&url, headers).await { + Ok(mut list) => { + crate::append_error_log(format!( + "env: by-repo returned {} env(s) for {owner}/{repo}", + list.len(), + )); + by_repo_envs.append(&mut list); + } + Err(e) => crate::append_error_log(format!( + "env: by-repo fetch failed for {owner}/{repo}: {e}" + )), + } + } + } + if let Some(env) = pick_environment_row(&by_repo_envs, desired_label.as_deref()) { + return Ok(AutodetectSelection { + id: env.id.clone(), + label: env.label.as_deref().map(str::to_owned), + }); + } + + // 2) Fallback to the full list + let list_url = if base_url.contains("/backend-api") { + format!("{base_url}/wham/environments") + } else { + format!("{base_url}/api/codex/environments") + }; + crate::append_error_log(format!("env: GET {list_url}")); + // Fetch and log the full environments JSON for debugging + let http = reqwest::Client::builder().build()?; + let res = http.get(&list_url).headers(headers.clone()).send().await?; + let status = res.status(); + let ct = res + .headers() + .get(CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + let body = res.text().await.unwrap_or_default(); + crate::append_error_log(format!("env: status={status} content-type={ct}")); + match serde_json::from_str::(&body) { + Ok(v) => { + let pretty = serde_json::to_string_pretty(&v).unwrap_or(body.clone()); + crate::append_error_log(format!("env: /environments JSON (pretty):\n{pretty}")); + } + Err(_) => crate::append_error_log(format!("env: /environments (raw):\n{body}")), + } + if !status.is_success() { + anyhow::bail!("GET {list_url} failed: {status}; content-type={ct}; body={body}"); + } + let all_envs: Vec = serde_json::from_str(&body).map_err(|e| { + anyhow::anyhow!("Decode error for {list_url}: {e}; content-type={ct}; body={body}") + })?; + if let Some(env) = pick_environment_row(&all_envs, desired_label.as_deref()) { + return Ok(AutodetectSelection { + id: env.id.clone(), + label: env.label.as_deref().map(str::to_owned), + }); + } + anyhow::bail!("no environments available") +} + +fn pick_environment_row( + envs: &[CodeEnvironment], + desired_label: Option<&str>, +) -> Option { + if envs.is_empty() { + return None; + } + if let Some(label) = desired_label { + let lc = label.to_lowercase(); + if let Some(e) = envs + .iter() + .find(|e| e.label.as_deref().unwrap_or("").to_lowercase() == lc) + { + crate::append_error_log(format!("env: matched by label: {label} -> {}", e.id)); + return Some(e.clone()); + } + } + if envs.len() == 1 { + crate::append_error_log("env: single environment available; selecting it"); + return Some(envs[0].clone()); + } + if let Some(e) = envs.iter().find(|e| e.is_pinned.unwrap_or(false)) { + crate::append_error_log(format!("env: selecting pinned environment: {}", e.id)); + return Some(e.clone()); + } + // Highest task_count as heuristic + if let Some(e) = envs + .iter() + .max_by_key(|e| e.task_count.unwrap_or(0)) + .or_else(|| envs.first()) + { + crate::append_error_log(format!("env: selecting by task_count/first: {}", e.id)); + return Some(e.clone()); + } + None +} + +async fn get_json( + url: &str, + headers: &HeaderMap, +) -> anyhow::Result { + let http = reqwest::Client::builder().build()?; + let res = http.get(url).headers(headers.clone()).send().await?; + let status = res.status(); + let ct = res + .headers() + .get(CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + let body = res.text().await.unwrap_or_default(); + crate::append_error_log(format!("env: status={status} content-type={ct}")); + if !status.is_success() { + anyhow::bail!("GET {url} failed: {status}; content-type={ct}; body={body}"); + } + let parsed = serde_json::from_str::(&body).map_err(|e| { + anyhow::anyhow!("Decode error for {url}: {e}; content-type={ct}; body={body}") + })?; + Ok(parsed) +} + +fn get_git_origins() -> Vec { + // Prefer: git config --get-regexp remote\..*\.url + let out = std::process::Command::new("git") + .args(["config", "--get-regexp", "remote\\..*\\.url"]) + .output(); + if let Ok(ok) = out + && ok.status.success() + { + let s = String::from_utf8_lossy(&ok.stdout); + let mut urls = Vec::new(); + for line in s.lines() { + if let Some((_, url)) = line.split_once(' ') { + urls.push(url.trim().to_string()); + } + } + if !urls.is_empty() { + return uniq(urls); + } + } + // Fallback: git remote -v + let out = std::process::Command::new("git") + .args(["remote", "-v"]) + .output(); + if let Ok(ok) = out + && ok.status.success() + { + let s = String::from_utf8_lossy(&ok.stdout); + let mut urls = Vec::new(); + for line in s.lines() { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 2 { + urls.push(parts[1].to_string()); + } + } + if !urls.is_empty() { + return uniq(urls); + } + } + Vec::new() +} + +fn uniq(mut v: Vec) -> Vec { + v.sort(); + v.dedup(); + v +} + +fn parse_owner_repo(url: &str) -> Option<(String, String)> { + // Normalize common prefixes and handle multiple SSH/HTTPS variants. + let mut s = url.trim().to_string(); + // Drop protocol scheme for ssh URLs + if let Some(rest) = s.strip_prefix("ssh://") { + s = rest.to_string(); + } + // Accept any user before @github.com (e.g., git@, org-123@) + if let Some(idx) = s.find("@github.com:") { + let rest = &s[idx + "@github.com:".len()..]; + let rest = rest.trim_start_matches('/').trim_end_matches(".git"); + let mut parts = rest.splitn(2, '/'); + let owner = parts.next()?.to_string(); + let repo = parts.next()?.to_string(); + crate::append_error_log(format!("env: parsed SSH GitHub origin => {owner}/{repo}")); + return Some((owner, repo)); + } + // HTTPS or git protocol + for prefix in [ + "https://github.com/", + "http://github.com/", + "git://github.com/", + "github.com/", + ] { + if let Some(rest) = s.strip_prefix(prefix) { + let rest = rest.trim_start_matches('/').trim_end_matches(".git"); + let mut parts = rest.splitn(2, '/'); + let owner = parts.next()?.to_string(); + let repo = parts.next()?.to_string(); + crate::append_error_log(format!("env: parsed HTTP GitHub origin => {owner}/{repo}")); + return Some((owner, repo)); + } + } + None +} + +/// List environments for the current repo(s) with a fallback to the global list. +/// Returns a de-duplicated, sorted set suitable for the TUI modal. +pub async fn list_environments( + base_url: &str, + headers: &HeaderMap, +) -> anyhow::Result> { + let mut map: HashMap = HashMap::new(); + + // 1) By-repo lookup for each parsed GitHub origin + let origins = get_git_origins(); + for origin in &origins { + if let Some((owner, repo)) = parse_owner_repo(origin) { + let url = if base_url.contains("/backend-api") { + format!( + "{}/wham/environments/by-repo/{}/{}/{}", + base_url, "github", owner, repo + ) + } else { + format!( + "{}/api/codex/environments/by-repo/{}/{}/{}", + base_url, "github", owner, repo + ) + }; + match get_json::>(&url, headers).await { + Ok(list) => { + info!("env_tui: by-repo {}:{} -> {} envs", owner, repo, list.len()); + for e in list { + let entry = + map.entry(e.id.clone()) + .or_insert_with(|| crate::app::EnvironmentRow { + id: e.id.clone(), + label: e.label.clone(), + is_pinned: e.is_pinned.unwrap_or(false), + repo_hints: Some(format!("{owner}/{repo}")), + }); + // Merge: keep label if present, or use new; accumulate pinned flag + if entry.label.is_none() { + entry.label = e.label.clone(); + } + entry.is_pinned = entry.is_pinned || e.is_pinned.unwrap_or(false); + if entry.repo_hints.is_none() { + entry.repo_hints = Some(format!("{owner}/{repo}")); + } + } + } + Err(e) => { + warn!( + "env_tui: by-repo fetch failed for {}/{}: {}", + owner, repo, e + ); + } + } + } + } + + // 2) Fallback to the full list; on error return what we have if any. + let list_url = if base_url.contains("/backend-api") { + format!("{base_url}/wham/environments") + } else { + format!("{base_url}/api/codex/environments") + }; + match get_json::>(&list_url, headers).await { + Ok(list) => { + info!("env_tui: global list -> {} envs", list.len()); + for e in list { + let entry = map + .entry(e.id.clone()) + .or_insert_with(|| crate::app::EnvironmentRow { + id: e.id.clone(), + label: e.label.clone(), + is_pinned: e.is_pinned.unwrap_or(false), + repo_hints: None, + }); + if entry.label.is_none() { + entry.label = e.label.clone(); + } + entry.is_pinned = entry.is_pinned || e.is_pinned.unwrap_or(false); + } + } + Err(e) => { + if map.is_empty() { + return Err(e); + } else { + warn!( + "env_tui: global list failed; using by-repo results only: {}", + e + ); + } + } + } + + let mut rows: Vec = map.into_values().collect(); + rows.sort_by(|a, b| { + // pinned first + let p = b.is_pinned.cmp(&a.is_pinned); + if p != std::cmp::Ordering::Equal { + return p; + } + // then label (ci), then id + let al = a.label.as_deref().unwrap_or("").to_lowercase(); + let bl = b.label.as_deref().unwrap_or("").to_lowercase(); + let l = al.cmp(&bl); + if l != std::cmp::Ordering::Equal { + return l; + } + a.id.cmp(&b.id) + }); + Ok(rows) +} diff --git a/codex-rs/cloud-tasks/src/lib.rs b/codex-rs/cloud-tasks/src/lib.rs new file mode 100644 index 00000000..d57a4057 --- /dev/null +++ b/codex-rs/cloud-tasks/src/lib.rs @@ -0,0 +1,1631 @@ +mod app; +mod cli; +pub mod env_detect; +mod new_task; +pub mod scrollable_diff; +mod ui; +pub mod util; +pub use cli::Cli; + +use std::io::IsTerminal; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; +use std::time::Instant; +use tokio::sync::mpsc::UnboundedSender; +use tracing::info; +use tracing_subscriber::EnvFilter; +use util::append_error_log; +use util::set_user_agent_suffix; + +struct ApplyJob { + task_id: codex_cloud_tasks_client::TaskId, + diff_override: Option, +} + +fn level_from_status(status: codex_cloud_tasks_client::ApplyStatus) -> app::ApplyResultLevel { + match status { + codex_cloud_tasks_client::ApplyStatus::Success => app::ApplyResultLevel::Success, + codex_cloud_tasks_client::ApplyStatus::Partial => app::ApplyResultLevel::Partial, + codex_cloud_tasks_client::ApplyStatus::Error => app::ApplyResultLevel::Error, + } +} + +fn spawn_preflight( + app: &mut app::App, + backend: &Arc, + tx: &UnboundedSender, + frame_tx: &UnboundedSender, + title: String, + job: ApplyJob, +) -> bool { + if app.apply_inflight { + app.status = "An apply is already running; wait for it to finish first.".to_string(); + return false; + } + if app.apply_preflight_inflight { + app.status = "A preflight is already running; wait for it to finish first.".to_string(); + return false; + } + + app.apply_preflight_inflight = true; + let _ = frame_tx.send(Instant::now() + Duration::from_millis(100)); + + let backend = backend.clone(); + let tx = tx.clone(); + tokio::spawn(async move { + let ApplyJob { + task_id, + diff_override, + } = job; + let result = codex_cloud_tasks_client::CloudBackend::apply_task_preflight( + &*backend, + task_id.clone(), + diff_override, + ) + .await; + + let event = match result { + Ok(outcome) => { + let level = level_from_status(outcome.status); + app::AppEvent::ApplyPreflightFinished { + id: task_id, + title, + message: outcome.message, + level, + skipped: outcome.skipped_paths, + conflicts: outcome.conflict_paths, + } + } + Err(e) => app::AppEvent::ApplyPreflightFinished { + id: task_id, + title, + message: format!("Preflight failed: {e}"), + level: app::ApplyResultLevel::Error, + skipped: Vec::new(), + conflicts: Vec::new(), + }, + }; + + let _ = tx.send(event); + }); + + true +} + +fn spawn_apply( + app: &mut app::App, + backend: &Arc, + tx: &UnboundedSender, + frame_tx: &UnboundedSender, + job: ApplyJob, +) -> bool { + if app.apply_inflight { + app.status = "An apply is already running; wait for it to finish first.".to_string(); + return false; + } + if app.apply_preflight_inflight { + app.status = "Finish the current preflight before starting another apply.".to_string(); + return false; + } + + app.apply_inflight = true; + let _ = frame_tx.send(Instant::now() + Duration::from_millis(100)); + + let backend = backend.clone(); + let tx = tx.clone(); + tokio::spawn(async move { + let ApplyJob { + task_id, + diff_override, + } = job; + let result = codex_cloud_tasks_client::CloudBackend::apply_task( + &*backend, + task_id.clone(), + diff_override, + ) + .await; + + let event = match result { + Ok(outcome) => app::AppEvent::ApplyFinished { + id: task_id, + result: Ok(outcome), + }, + Err(e) => app::AppEvent::ApplyFinished { + id: task_id, + result: Err(format!("{e}")), + }, + }; + + let _ = tx.send(event); + }); + + true +} + +// logging helper lives in util module + +// (no standalone patch summarizer needed – UI displays raw diffs) + +/// Entry point for the `codex cloud` subcommand. +pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option) -> anyhow::Result<()> { + // Very minimal logging setup; mirrors other crates' pattern. + let default_level = "error"; + let _ = tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env() + .or_else(|_| EnvFilter::try_new(default_level)) + .unwrap_or_else(|_| EnvFilter::new(default_level)), + ) + .with_ansi(std::io::stderr().is_terminal()) + .with_writer(std::io::stderr) + .try_init(); + + info!("Launching Cloud Tasks list UI"); + set_user_agent_suffix("codex_cloud_tasks_tui"); + + // Default to online unless explicitly configured to use mock. + let use_mock = matches!( + std::env::var("CODEX_CLOUD_TASKS_MODE").ok().as_deref(), + Some("mock") | Some("MOCK") + ); + + let backend: Arc = if use_mock { + Arc::new(codex_cloud_tasks_client::MockClient) + } else { + // Build an HTTP client against the configured (or default) base URL. + let base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL") + .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()); + let ua = codex_core::default_client::get_codex_user_agent(); + let mut http = + codex_cloud_tasks_client::HttpClient::new(base_url.clone())?.with_user_agent(ua); + // Log which base URL and path style we're going to use. + let style = if base_url.contains("/backend-api") { + "wham" + } else { + "codex-api" + }; + append_error_log(format!("startup: base_url={base_url} path_style={style}")); + + // Require ChatGPT login (SWIC). Exit with a clear message if missing. + let _token = match codex_core::config::find_codex_home() + .ok() + .map(codex_login::AuthManager::new) + .and_then(|am| am.auth()) + { + Some(auth) => { + // Log account context for debugging workspace selection. + if let Some(acc) = auth.get_account_id() { + append_error_log(format!("auth: mode=ChatGPT account_id={acc}")); + } + match auth.get_token().await { + Ok(t) if !t.is_empty() => { + // Attach token and ChatGPT-Account-Id header if available + http = http.with_bearer_token(t.clone()); + if let Some(acc) = auth + .get_account_id() + .or_else(|| util::extract_chatgpt_account_id(&t)) + { + append_error_log(format!("auth: set ChatGPT-Account-Id header: {acc}")); + http = http.with_chatgpt_account_id(acc); + } + t + } + _ => { + eprintln!( + "Not signed in. Please run 'codex login' to sign in with ChatGPT, then re-run 'codex cloud'." + ); + std::process::exit(1); + } + } + } + None => { + eprintln!( + "Not signed in. Please run 'codex login' to sign in with ChatGPT, then re-run 'codex cloud'." + ); + std::process::exit(1); + } + }; + Arc::new(http) + }; + + // Terminal setup + use crossterm::ExecutableCommand; + use crossterm::event::DisableBracketedPaste; + use crossterm::event::EnableBracketedPaste; + use crossterm::event::KeyboardEnhancementFlags; + use crossterm::event::PopKeyboardEnhancementFlags; + use crossterm::event::PushKeyboardEnhancementFlags; + use crossterm::terminal::EnterAlternateScreen; + use crossterm::terminal::LeaveAlternateScreen; + use crossterm::terminal::disable_raw_mode; + use crossterm::terminal::enable_raw_mode; + use ratatui::Terminal; + use ratatui::backend::CrosstermBackend; + let mut stdout = std::io::stdout(); + enable_raw_mode()?; + stdout.execute(EnterAlternateScreen)?; + stdout.execute(EnableBracketedPaste)?; + // Enable enhanced key reporting so Shift+Enter is distinguishable from Enter. + // Some terminals may not support these flags; ignore errors if enabling fails. + let _ = crossterm::execute!( + std::io::stdout(), + PushKeyboardEnhancementFlags( + KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES + | KeyboardEnhancementFlags::REPORT_EVENT_TYPES + | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS + ) + ); + let backend_ui = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend_ui)?; + terminal.clear()?; + + // App state + let mut app = app::App::new(); + // Initial load + let force_internal = matches!( + std::env::var("CODEX_CLOUD_TASKS_FORCE_INTERNAL") + .ok() + .as_deref(), + Some("1") | Some("true") | Some("TRUE") + ); + append_error_log(format!( + "startup: wham_force_internal={} ua={}", + force_internal, + codex_core::default_client::get_codex_user_agent() + )); + // Non-blocking initial load so the in-box spinner can animate + app.status = "Loading tasks…".to_string(); + app.refresh_inflight = true; + // New list generation; reset background enrichment coordination + app.list_generation = app.list_generation.saturating_add(1); + app.in_flight.clear(); + // reset any in-flight enrichment state + + // Event stream + use crossterm::event::Event; + use crossterm::event::EventStream; + use crossterm::event::KeyCode; + use crossterm::event::KeyEventKind; + use crossterm::event::KeyModifiers; + use tokio_stream::StreamExt; + let mut events = EventStream::new(); + + // Channel for non-blocking background loads + use tokio::sync::mpsc::unbounded_channel; + let (tx, mut rx) = unbounded_channel::(); + // Kick off the initial load in background + { + let backend = Arc::clone(&backend); + let tx = tx.clone(); + tokio::spawn(async move { + let res = app::load_tasks(&*backend, None).await; + let _ = tx.send(app::AppEvent::TasksLoaded { + env: None, + result: res, + }); + }); + } + // Fetch environment list in parallel so the header can show friendly names quickly. + { + let tx = tx.clone(); + tokio::spawn(async move { + let base_url = util::normalize_base_url( + &std::env::var("CODEX_CLOUD_TASKS_BASE_URL") + .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()), + ); + let headers = util::build_chatgpt_headers().await; + let res = crate::env_detect::list_environments(&base_url, &headers).await; + let _ = tx.send(app::AppEvent::EnvironmentsLoaded(res)); + }); + } + + // Try to auto-detect a likely environment id on startup and refresh if found. + // Do this concurrently so the initial list shows quickly; on success we refetch with filter. + { + let tx = tx.clone(); + tokio::spawn(async move { + let base_url = util::normalize_base_url( + &std::env::var("CODEX_CLOUD_TASKS_BASE_URL") + .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()), + ); + // Build headers: UA + ChatGPT auth if available + let headers = util::build_chatgpt_headers().await; + + // Run autodetect. If it fails, we keep using "All". + let res = crate::env_detect::autodetect_environment_id(&base_url, &headers, None).await; + let _ = tx.send(app::AppEvent::EnvironmentAutodetected(res)); + }); + } + + // Event-driven redraws with a tiny coalescing scheduler (snappy UI, no fixed 250ms tick). + let mut needs_redraw = true; + use std::time::Instant; + use tokio::time::Instant as TokioInstant; + use tokio::time::sleep_until; + let (frame_tx, mut frame_rx) = tokio::sync::mpsc::unbounded_channel::(); + let (redraw_tx, mut redraw_rx) = tokio::sync::mpsc::unbounded_channel::<()>(); + + // Coalesce frame requests to the earliest deadline; emit a single redraw signal. + tokio::spawn(async move { + let mut next_deadline: Option = None; + loop { + let target = + next_deadline.unwrap_or_else(|| Instant::now() + Duration::from_secs(24 * 60 * 60)); + let sleeper = sleep_until(TokioInstant::from_std(target)); + tokio::pin!(sleeper); + tokio::select! { + recv = frame_rx.recv() => { + match recv { + Some(at) => { + if next_deadline.is_none_or(|cur| at < cur) { + next_deadline = Some(at); + } + continue; // recompute sleep target + } + None => break, + } + } + _ = &mut sleeper => { + if next_deadline.take().is_some() { + let _ = redraw_tx.send(()); + } + } + } + } + }); + // Kick an initial draw so the UI appears immediately. + let _ = frame_tx.send(Instant::now()); + + // Render helper to centralize immediate redraws after handling events. + let render_if_needed = |terminal: &mut Terminal>, + app: &mut app::App, + needs_redraw: &mut bool| + -> anyhow::Result<()> { + if *needs_redraw { + terminal.draw(|f| ui::draw(f, app))?; + *needs_redraw = false; + } + Ok(()) + }; + + let exit_code = loop { + tokio::select! { + // Coalesced redraw requests: spinner animation and paste-burst micro‑flush. + Some(()) = redraw_rx.recv() => { + // Micro‑flush pending first key held by paste‑burst. + if let Some(page) = app.new_task.as_mut() { + if page.composer.flush_paste_burst_if_due() { needs_redraw = true; } + if page.composer.is_in_paste_burst() { + let _ = frame_tx.send(Instant::now() + codex_tui::ComposerInput::recommended_flush_delay()); + } + } + // Advance throbber only while loading. + if app.refresh_inflight + || app.details_inflight + || app.env_loading + || app.apply_preflight_inflight + || app.apply_inflight + { + app.throbber.calc_next(); + needs_redraw = true; + let _ = frame_tx.send(Instant::now() + Duration::from_millis(100)); + } + render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?; + } + maybe_app_event = rx.recv() => { + if let Some(ev) = maybe_app_event { + match ev { + app::AppEvent::TasksLoaded { env, result } => { + // Only apply results for the current filter to avoid races. + if env.as_deref() != app.env_filter.as_deref() { + append_error_log(format!( + "refresh.drop: env={} current={}", + env.clone().unwrap_or_else(|| "".to_string()), + app.env_filter.clone().unwrap_or_else(|| "".to_string()) + )); + continue; + } + app.refresh_inflight = false; + match result { + Ok(tasks) => { + append_error_log(format!( + "refresh.apply: env={} count={}", + env.clone().unwrap_or_else(|| "".to_string()), + tasks.len() + )); + app.tasks = tasks; + if app.selected >= app.tasks.len() { app.selected = app.tasks.len().saturating_sub(1); } + app.status = "Loaded tasks".to_string(); + } + Err(e) => { + append_error_log(format!("refresh load_tasks failed: {e}")); + app.status = format!("Failed to load tasks: {e}"); + } + } + needs_redraw = true; + let _ = frame_tx.send(Instant::now()); + } + app::AppEvent::NewTaskSubmitted(result) => { + match result { + Ok(created) => { + append_error_log(format!("new-task: created id={}", created.id.0)); + app.status = format!("Submitted as {}", created.id.0); + app.new_task = None; + // Refresh tasks in background for current filter + app.status = format!("Submitted as {} — refreshing…", created.id.0); + app.refresh_inflight = true; + app.list_generation = app.list_generation.saturating_add(1); + needs_redraw = true; + let backend = Arc::clone(&backend); + let tx = tx.clone(); + let env_sel = app.env_filter.clone(); + tokio::spawn(async move { + let res = app::load_tasks(&*backend, env_sel.as_deref()).await; + let _ = tx.send(app::AppEvent::TasksLoaded { env: env_sel, result: res }); + }); + let _ = frame_tx.send(Instant::now()); + } + Err(msg) => { + append_error_log(format!("new-task: submit failed: {msg}")); + if let Some(page) = app.new_task.as_mut() { page.submitting = false; } + app.status = format!("Submit failed: {msg}. See error.log for details."); + needs_redraw = true; + let _ = frame_tx.send(Instant::now()); + } + } + } + // (removed TaskSummaryUpdated; unused in this prototype) + app::AppEvent::ApplyPreflightFinished { id, title, message, level, skipped, conflicts } => { + // Only update if modal is still open and ids match + if let Some(m) = app.apply_modal.as_mut() + && m.task_id == id + { + m.title = title; + m.result_message = Some(message); + m.result_level = Some(level); + m.skipped_paths = skipped; + m.conflict_paths = conflicts; + app.apply_preflight_inflight = false; + needs_redraw = true; + let _ = frame_tx.send(Instant::now()); + } + } + app::AppEvent::EnvironmentsLoaded(result) => { + app.env_loading = false; + match result { + Ok(list) => { + app.environments = list; + app.env_error = None; + app.env_last_loaded = Some(std::time::Instant::now()); + } + Err(e) => { + app.env_error = Some(e.to_string()); + } + } + needs_redraw = true; + let _ = frame_tx.send(Instant::now()); + } + app::AppEvent::EnvironmentAutodetected(result) => { + if let Ok(sel) = result { + // Only apply if user hasn't set a filter yet or it's different. + if app.env_filter.as_deref() != Some(sel.id.as_str()) { + append_error_log(format!( + "env.select: autodetected id={} label={}", + sel.id, + sel.label.clone().unwrap_or_else(|| "".to_string()) + )); + // Preseed environments with detected label so header can show it even before list arrives + if let Some(lbl) = sel.label.clone() { + let present = app.environments.iter().any(|r| r.id == sel.id); + if !present { + app.environments.push(app::EnvironmentRow { id: sel.id.clone(), label: Some(lbl), is_pinned: false, repo_hints: None }); + } + } + app.env_filter = Some(sel.id); + app.status = "Loading tasks…".to_string(); + app.refresh_inflight = true; + app.list_generation = app.list_generation.saturating_add(1); + app.in_flight.clear(); + // reset spinner state + needs_redraw = true; + { + let backend = Arc::clone(&backend); + let tx = tx.clone(); + let env_sel = app.env_filter.clone(); + tokio::spawn(async move { + let res = app::load_tasks(&*backend, env_sel.as_deref()).await; + let _ = tx.send(app::AppEvent::TasksLoaded { env: env_sel, result: res }); + }); + } + // Proactively fetch environments to resolve a friendly name for the header. + app.env_loading = true; + { + let tx = tx.clone(); + tokio::spawn(async move { + let base_url = crate::util::normalize_base_url( + &std::env::var("CODEX_CLOUD_TASKS_BASE_URL") + .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()), + ); + let headers = crate::util::build_chatgpt_headers().await; + let res = crate::env_detect::list_environments(&base_url, &headers).await; + let _ = tx.send(app::AppEvent::EnvironmentsLoaded(res)); + }); + } + let _ = frame_tx.send(Instant::now()); + } + } + // on Err, silently continue with All + } + app::AppEvent::DetailsDiffLoaded { id, title, diff } => { + if let Some(ov) = &app.diff_overlay + && ov.task_id != id { + continue; + } + let diff_lines: Vec = diff.lines().map(str::to_string).collect(); + if let Some(ov) = app.diff_overlay.as_mut() { + ov.title = title; + { + let base = ov.base_attempt_mut(); + base.diff_lines = diff_lines.clone(); + base.diff_raw = Some(diff.clone()); + } + ov.base_can_apply = true; + ov.apply_selection_to_fields(); + } else { + let mut overlay = app::DiffOverlay::new(id.clone(), title, None); + { + let base = overlay.base_attempt_mut(); + base.diff_lines = diff_lines.clone(); + base.diff_raw = Some(diff.clone()); + } + overlay.base_can_apply = true; + overlay.current_view = app::DetailView::Diff; + overlay.apply_selection_to_fields(); + app.diff_overlay = Some(overlay); + } + app.details_inflight = false; + app.status.clear(); + needs_redraw = true; + } + app::AppEvent::DetailsMessagesLoaded { + id, + title, + messages, + prompt, + turn_id, + sibling_turn_ids, + attempt_placement, + attempt_status, + } => { + if let Some(ov) = &app.diff_overlay + && ov.task_id != id { + continue; + } + let conv = conversation_lines(prompt.clone(), &messages); + if let Some(ov) = app.diff_overlay.as_mut() { + ov.title = title.clone(); + { + let base = ov.base_attempt_mut(); + base.text_lines = conv.clone(); + base.prompt = prompt.clone(); + base.turn_id = turn_id.clone(); + base.status = attempt_status; + base.attempt_placement = attempt_placement; + } + ov.base_turn_id = turn_id.clone(); + ov.sibling_turn_ids = sibling_turn_ids.clone(); + ov.attempt_total_hint = Some(sibling_turn_ids.len().saturating_add(1)); + if !ov.base_can_apply { + ov.current_view = app::DetailView::Prompt; + } + ov.apply_selection_to_fields(); + if let (Some(turn_id), true) = (turn_id.clone(), !sibling_turn_ids.is_empty()) + && ov.attempts.len() == 1 { + let backend = Arc::clone(&backend); + let tx = tx.clone(); + let task_id = id.clone(); + tokio::spawn(async move { + match codex_cloud_tasks_client::CloudBackend::list_sibling_attempts( + &*backend, + task_id.clone(), + turn_id, + ) + .await + { + Ok(attempts) => { + let _ = tx.send(app::AppEvent::AttemptsLoaded { id: task_id, attempts }); + } + Err(e) => { + crate::util::append_error_log(format!( + "attempts.load failed for {}: {e}", + task_id.0 + )); + } + } + }); + } + } else { + let mut overlay = app::DiffOverlay::new(id.clone(), title, None); + { + let base = overlay.base_attempt_mut(); + base.text_lines = conv.clone(); + base.prompt = prompt.clone(); + base.turn_id = turn_id.clone(); + base.status = attempt_status; + base.attempt_placement = attempt_placement; + } + overlay.base_turn_id = turn_id.clone(); + overlay.sibling_turn_ids = sibling_turn_ids.clone(); + overlay.attempt_total_hint = Some(sibling_turn_ids.len().saturating_add(1)); + overlay.current_view = app::DetailView::Prompt; + overlay.apply_selection_to_fields(); + app.diff_overlay = Some(overlay); + } + app.details_inflight = false; + app.status.clear(); + needs_redraw = true; + } + app::AppEvent::AttemptsLoaded { id, attempts } => { + if let Some(ov) = app.diff_overlay.as_mut() { + if ov.task_id != id { + continue; + } + for attempt in attempts { + if ov + .attempts + .iter() + .any(|existing| existing.turn_id.as_deref() == Some(attempt.turn_id.as_str())) + { + continue; + } + let diff_lines = attempt + .diff + .as_ref() + .map(|d| d.lines().map(str::to_string).collect()) + .unwrap_or_default(); + let text_lines = conversation_lines(None, &attempt.messages); + ov.attempts.push(app::AttemptView { + turn_id: Some(attempt.turn_id.clone()), + status: attempt.status, + attempt_placement: attempt.attempt_placement, + diff_lines, + text_lines, + prompt: None, + diff_raw: attempt.diff.clone(), + }); + } + if ov.attempts.len() > 1 { + let (_, rest) = ov.attempts.split_at_mut(1); + rest.sort_by(|a, b| match (a.attempt_placement, b.attempt_placement) { + (Some(lhs), Some(rhs)) => lhs.cmp(&rhs), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => a.turn_id.cmp(&b.turn_id), + }); + } + if ov.selected_attempt >= ov.attempts.len() { + ov.selected_attempt = ov.attempts.len().saturating_sub(1); + } + ov.attempt_total_hint = Some(ov.attempts.len()); + ov.apply_selection_to_fields(); + needs_redraw = true; + } + } + app::AppEvent::DetailsFailed { id, title, error } => { + if let Some(ov) = &app.diff_overlay + && ov.task_id != id { + continue; + } + append_error_log(format!("details failed for {}: {error}", id.0)); + let pretty = pretty_lines_from_error(&error); + if let Some(ov) = app.diff_overlay.as_mut() { + ov.title = title.clone(); + { + let base = ov.base_attempt_mut(); + base.diff_lines.clear(); + base.text_lines = pretty.clone(); + base.prompt = None; + } + ov.base_can_apply = false; + ov.current_view = app::DetailView::Prompt; + ov.apply_selection_to_fields(); + } else { + let mut overlay = app::DiffOverlay::new(id.clone(), title, None); + { + let base = overlay.base_attempt_mut(); + base.text_lines = pretty; + } + overlay.base_can_apply = false; + overlay.current_view = app::DetailView::Prompt; + overlay.apply_selection_to_fields(); + app.diff_overlay = Some(overlay); + } + app.details_inflight = false; + needs_redraw = true; + } + app::AppEvent::ApplyFinished { id, result } => { + // Only update if the modal still corresponds to this id. + if let Some(m) = &app.apply_modal { + if m.task_id != id { continue; } + } else { + continue; + } + app.apply_inflight = false; + match result { + Ok(outcome) => { + app.status = outcome.message.clone(); + if matches!(outcome.status, codex_cloud_tasks_client::ApplyStatus::Success) { + app.apply_modal = None; + app.diff_overlay = None; + // Refresh tasks after successful apply + let backend = Arc::clone(&backend); + let tx = tx.clone(); + let env_sel = app.env_filter.clone(); + tokio::spawn(async move { + let res = app::load_tasks(&*backend, env_sel.as_deref()).await; + let _ = tx.send(app::AppEvent::TasksLoaded { env: env_sel, result: res }); + }); + } + } + Err(e) => { + append_error_log(format!("apply_task failed for {}: {e}", id.0)); + app.status = format!("Apply failed: {e}"); + } + } + needs_redraw = true; + } + } + } + // Render immediately after processing app events. + render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?; + } + maybe_event = events.next() => { + match maybe_event { + Some(Ok(Event::Paste(pasted))) => { + if app.env_modal.is_some() { + if let Some(m) = app.env_modal.as_mut() { + for ch in pasted.chars() { + match ch { + '\r' | '\n' => continue, + '\t' => m.query.push(' '), + _ => m.query.push(ch), + } + } + } + needs_redraw = true; + } else if let Some(page) = app.new_task.as_mut() + && !page.submitting + { + if page.composer.handle_paste(pasted) { + needs_redraw = true; + } + let _ = frame_tx.send(Instant::now()); + } + } + Some(Ok(Event::Key(key))) if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) => { + // Treat Ctrl-C like pressing 'q' in the current context. + if key.modifiers.contains(KeyModifiers::CONTROL) + && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C')) + { + if app.env_modal.is_some() { + // Close environment selector if open (don’t quit composer). + app.env_modal = None; + needs_redraw = true; + } else if app.best_of_modal.is_some() { + app.best_of_modal = None; + needs_redraw = true; + } else if app.apply_modal.is_some() { + app.apply_modal = None; + app.status = "Apply canceled".to_string(); + needs_redraw = true; + } else if app.new_task.is_some() { + app.new_task = None; + app.status = "Canceled new task".to_string(); + needs_redraw = true; + } else if app.diff_overlay.is_some() { + app.diff_overlay = None; + needs_redraw = true; + } else { + break 0; + } + // Render updated state immediately before continuing to next loop iteration. + render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?; + // Render after New Task branch to reflect input changes immediately. + render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?; + continue; + } + let is_ctrl_n = key.modifiers.contains(KeyModifiers::CONTROL) + && matches!(key.code, KeyCode::Char('n') | KeyCode::Char('N')) + || matches!(key.code, KeyCode::Char('\u{000E}')); + if is_ctrl_n { + if app.best_of_modal.is_some() { + app.best_of_modal = None; + needs_redraw = true; + } else { + let selected = app.best_of_n.saturating_sub(1).min(3); + app.best_of_modal = Some(app::BestOfModalState { selected }); + app.status = format!( + "Select best-of attempts (current: {} attempt{})", + app.best_of_n, + if app.best_of_n == 1 { "" } else { "s" } + ); + needs_redraw = true; + } + render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?; + continue; + } + if app.best_of_modal.is_some() { + match key.code { + KeyCode::Esc => { + app.best_of_modal = None; + needs_redraw = true; + } + KeyCode::Down | KeyCode::Char('j') => { + if let Some(m) = app.best_of_modal.as_mut() { + m.selected = (m.selected + 1).min(3); + } + needs_redraw = true; + } + KeyCode::Up | KeyCode::Char('k') => { + if let Some(m) = app.best_of_modal.as_mut() { + m.selected = m.selected.saturating_sub(1); + } + needs_redraw = true; + } + KeyCode::Char('1') | KeyCode::Char('2') | KeyCode::Char('3') | KeyCode::Char('4') => { + if let Some(m) = app.best_of_modal.as_mut() { + let val = match key.code { + KeyCode::Char('1') => 0, + KeyCode::Char('2') => 1, + KeyCode::Char('3') => 2, + KeyCode::Char('4') => 3, + _ => m.selected, + }; + m.selected = val; + } + needs_redraw = true; + } + KeyCode::Enter => { + if let Some(state) = app.best_of_modal.take() { + let new_value = state.selected + 1; + app.best_of_n = new_value; + if let Some(page) = app.new_task.as_mut() { + page.best_of_n = new_value; + } + append_error_log(format!("best-of.select: attempts={new_value}")); + app.status = format!( + "Best-of updated to {new_value} attempt{}", + if new_value == 1 { "" } else { "s" } + ); + needs_redraw = true; + } + } + _ => {} + } + render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?; + continue; + } + // New Task page: Ctrl+O opens environment switcher while composing. + let is_ctrl_o = key.modifiers.contains(KeyModifiers::CONTROL) + && matches!(key.code, KeyCode::Char('o') | KeyCode::Char('O')) + || matches!(key.code, KeyCode::Char('\u{000F}')); + if is_ctrl_o && app.new_task.is_some() { + // Close task modal/pending apply if present before opening env modal + app.diff_overlay = None; + app.env_modal = Some(app::EnvModalState { query: String::new(), selected: 0 }); + // Cache environments until user explicitly refreshes with 'r' inside the modal. + let should_fetch = app.environments.is_empty(); + if should_fetch { + app.env_loading = true; + app.env_error = None; + // Ensure spinner animates while loading environments. + let _ = frame_tx.send(Instant::now() + Duration::from_millis(100)); + } + needs_redraw = true; + if should_fetch { + let tx = tx.clone(); + tokio::spawn(async move { + let base_url = crate::util::normalize_base_url(&std::env::var("CODEX_CLOUD_TASKS_BASE_URL").unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string())); + let headers = crate::util::build_chatgpt_headers().await; + let res = crate::env_detect::list_environments(&base_url, &headers).await; + let _ = tx.send(app::AppEvent::EnvironmentsLoaded(res)); + }); + } + // Render after opening env modal to show it instantly. + render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?; + continue; + } + + // New Task page has priority when active, unless an env modal is open. + if let Some(page) = app.new_task.as_mut() { + if app.env_modal.is_some() { + // Defer handling to env-modal branch below. + } else { + match key.code { + KeyCode::Esc => { + app.new_task = None; + app.status = "Canceled new task".to_string(); + needs_redraw = true; + } + _ => { + if page.submitting { + // Ignore input while submitting + } else if let codex_tui::ComposerAction::Submitted(text) = page.composer.input(key) { + // Submit only if we have an env id + if let Some(env) = page.env_id.clone() { + append_error_log(format!( + "new-task: submit env={} size={}", + env, + text.chars().count() + )); + page.submitting = true; + app.status = "Submitting new task…".to_string(); + let tx = tx.clone(); + let backend = Arc::clone(&backend); + let best_of_n = page.best_of_n; + tokio::spawn(async move { + let result = codex_cloud_tasks_client::CloudBackend::create_task(&*backend, &env, &text, "main", false, best_of_n).await; + let evt = match result { + Ok(ok) => app::AppEvent::NewTaskSubmitted(Ok(ok)), + Err(e) => app::AppEvent::NewTaskSubmitted(Err(format!("{e}"))), + }; + let _ = tx.send(evt); + }); + } else { + app.status = "No environment selected (press 'e' to choose)".to_string(); + } + } + needs_redraw = true; + // If paste‑burst is active, schedule a micro‑flush frame. + if page.composer.is_in_paste_burst() { + let _ = frame_tx.send(Instant::now() + codex_tui::ComposerInput::recommended_flush_delay()); + } + // Always schedule an immediate redraw for key edits in the composer. + let _ = frame_tx.send(Instant::now()); + // Draw now so non-char edits (e.g., Option+Delete) reflect instantly. + render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?; + } + } + continue; + } + } + // If a diff overlay is open, handle its keys first. + if app.apply_modal.is_some() { + // Simple apply confirmation modal: y apply, p preflight, n/Esc cancel + match key.code { + KeyCode::Char('y') => { + if let Some(m) = app.apply_modal.as_ref() { + let title = m.title.clone(); + let job = ApplyJob { + task_id: m.task_id.clone(), + diff_override: m.diff_override.clone(), + }; + if spawn_apply(&mut app, &backend, &tx, &frame_tx, job) { + app.status = format!("Applying '{title}'..."); + } + needs_redraw = true; + } + } + KeyCode::Char('p') => { + if let Some(m) = app.apply_modal.take() { + let title = m.title.clone(); + let job = ApplyJob { + task_id: m.task_id.clone(), + diff_override: m.diff_override.clone(), + }; + if spawn_preflight(&mut app, &backend, &tx, &frame_tx, title.clone(), job) { + app.apply_modal = Some(app::ApplyModalState { + task_id: m.task_id, + title: title.clone(), + result_message: None, + result_level: None, + skipped_paths: Vec::new(), + conflict_paths: Vec::new(), + diff_override: m.diff_override, + }); + app.status = format!("Preflighting '{title}'..."); + } else { + app.apply_modal = Some(m); + } + needs_redraw = true; + } + } + KeyCode::Esc + | KeyCode::Char('n') + | KeyCode::Char('q') + | KeyCode::Char('Q') => { app.apply_modal = None; app.status = "Apply canceled".to_string(); needs_redraw = true; } + _ => {} + } + } else if app.diff_overlay.is_some() { + let mut cycle_attempt = |delta: isize| { + if let Some(ov) = app.diff_overlay.as_mut() + && ov.attempt_count() > 1 { + ov.step_attempt(delta); + let total = ov.attempt_display_total(); + let current = ov.selected_attempt + 1; + app.status = format!("Viewing attempt {current} of {total}"); + ov.sd.to_top(); + needs_redraw = true; + } + }; + + match key.code { + KeyCode::Char('a') => { + if app.apply_inflight || app.apply_preflight_inflight { + app.status = "Finish the current apply/preflight before starting another.".to_string(); + needs_redraw = true; + continue; + } + let snapshot = app.diff_overlay.as_ref().map(|ov| { + ( + ov.task_id.clone(), + ov.title.clone(), + ov.current_can_apply(), + ov.current_attempt().and_then(|attempt| attempt.diff_raw.clone()), + ) + }); + if let Some((task_id, title, can_apply, diff_override)) = snapshot { + if can_apply { + let job = ApplyJob { + task_id: task_id.clone(), + diff_override: diff_override.clone(), + }; + if spawn_preflight(&mut app, &backend, &tx, &frame_tx, title.clone(), job) { + app.apply_modal = Some(app::ApplyModalState { + task_id, + title: title.clone(), + result_message: None, + result_level: None, + skipped_paths: Vec::new(), + conflict_paths: Vec::new(), + diff_override, + }); + app.status = format!("Preflighting '{title}'..."); + } + } else { + app.status = "No diff available to apply.".to_string(); + } + needs_redraw = true; + } + } + KeyCode::Tab => { + cycle_attempt(1); + } + KeyCode::BackTab => { + cycle_attempt(-1); + } + // From task modal, 'o' should close it and open the env selector + KeyCode::Char('o') | KeyCode::Char('O') => { + app.diff_overlay = None; + app.env_modal = Some(app::EnvModalState { query: String::new(), selected: 0 }); + // Use cached environments unless empty + if app.environments.is_empty() { app.env_loading = true; app.env_error = None; } + needs_redraw = true; + if app.environments.is_empty() { + let tx = tx.clone(); + tokio::spawn(async move { + let base_url = crate::util::normalize_base_url( + &std::env::var("CODEX_CLOUD_TASKS_BASE_URL") + .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()), + ); + let headers = crate::util::build_chatgpt_headers().await; + let res = crate::env_detect::list_environments(&base_url, &headers).await; + let _ = tx.send(app::AppEvent::EnvironmentsLoaded(res)); + }); + } + } + KeyCode::Left => { + if let Some(ov) = &mut app.diff_overlay { + let has_text = ov.current_attempt().is_some_and(app::AttemptView::has_text); + let has_diff = ov.current_attempt().is_some_and(app::AttemptView::has_diff) || ov.base_can_apply; + if has_text && has_diff { + ov.set_view(app::DetailView::Prompt); + ov.sd.to_top(); + needs_redraw = true; + } + } + } + KeyCode::Right => { + if let Some(ov) = &mut app.diff_overlay { + let has_text = ov.current_attempt().is_some_and(app::AttemptView::has_text); + let has_diff = ov.current_attempt().is_some_and(app::AttemptView::has_diff) || ov.base_can_apply; + if has_text && has_diff { + ov.set_view(app::DetailView::Diff); + ov.sd.to_top(); + needs_redraw = true; + } + } + } + KeyCode::Char(']') | KeyCode::Char('}') => { + cycle_attempt(1); + } + KeyCode::Char('[') | KeyCode::Char('{') => { + cycle_attempt(-1); + } + KeyCode::Esc | KeyCode::Char('q') => { + app.diff_overlay = None; + needs_redraw = true; + } + KeyCode::Down | KeyCode::Char('j') => { + if let Some(ov) = &mut app.diff_overlay { ov.sd.scroll_by(1); } + needs_redraw = true; + } + KeyCode::Up | KeyCode::Char('k') => { + if let Some(ov) = &mut app.diff_overlay { ov.sd.scroll_by(-1); } + needs_redraw = true; + } + KeyCode::PageDown | KeyCode::Char(' ') => { + if let Some(ov) = &mut app.diff_overlay { let step = ov.sd.state.viewport_h.saturating_sub(1) as i16; ov.sd.page_by(step); } + needs_redraw = true; + } + KeyCode::PageUp => { + if let Some(ov) = &mut app.diff_overlay { let step = ov.sd.state.viewport_h.saturating_sub(1) as i16; ov.sd.page_by(-step); } + needs_redraw = true; + } + KeyCode::Home => { if let Some(ov) = &mut app.diff_overlay { ov.sd.to_top(); } needs_redraw = true; } + KeyCode::End => { if let Some(ov) = &mut app.diff_overlay { ov.sd.to_bottom(); } needs_redraw = true; } + _ => {} + } + } else if app.env_modal.is_some() { + // Environment modal key handling + match key.code { + KeyCode::Esc => { app.env_modal = None; needs_redraw = true; } + KeyCode::Char('r') | KeyCode::Char('R') => { + // Trigger refresh of environments + app.env_loading = true; app.env_error = None; needs_redraw = true; + let _ = frame_tx.send(Instant::now() + Duration::from_millis(100)); + let tx = tx.clone(); + tokio::spawn(async move { + let base_url = crate::util::normalize_base_url(&std::env::var("CODEX_CLOUD_TASKS_BASE_URL").unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string())); + let headers = crate::util::build_chatgpt_headers().await; + let res = crate::env_detect::list_environments(&base_url, &headers).await; + let _ = tx.send(app::AppEvent::EnvironmentsLoaded(res)); + }); + } + KeyCode::Char(ch) if !key.modifiers.contains(KeyModifiers::CONTROL) && !key.modifiers.contains(KeyModifiers::ALT) => { + if let Some(m) = app.env_modal.as_mut() { m.query.push(ch); } + needs_redraw = true; + } + KeyCode::Backspace => { if let Some(m) = app.env_modal.as_mut() { m.query.pop(); } needs_redraw = true; } + KeyCode::Down | KeyCode::Char('j') => { if let Some(m) = app.env_modal.as_mut() { m.selected = m.selected.saturating_add(1); } needs_redraw = true; } + KeyCode::Up | KeyCode::Char('k') => { if let Some(m) = app.env_modal.as_mut() { m.selected = m.selected.saturating_sub(1); } needs_redraw = true; } + KeyCode::Home => { if let Some(m) = app.env_modal.as_mut() { m.selected = 0; } needs_redraw = true; } + KeyCode::End => { if let Some(m) = app.env_modal.as_mut() { m.selected = app.environments.len(); } needs_redraw = true; } + KeyCode::PageDown | KeyCode::Char(' ') => { if let Some(m) = app.env_modal.as_mut() { let step = 10usize; m.selected = m.selected.saturating_add(step); } needs_redraw = true; } + KeyCode::PageUp => { if let Some(m) = app.env_modal.as_mut() { let step = 10usize; m.selected = m.selected.saturating_sub(step); } needs_redraw = true; } + KeyCode::Char('n') => { + if app.env_filter.is_none() { + app.new_task = Some(crate::new_task::NewTaskPage::new(None, app.best_of_n)); + } else { + app.new_task = Some(crate::new_task::NewTaskPage::new(app.env_filter.clone(), app.best_of_n)); + } + app.status = "New Task: Enter to submit; Esc to cancel".to_string(); + needs_redraw = true; + } + KeyCode::Enter => { + // Resolve selection over filtered set + if let Some(state) = app.env_modal.take() { + let q = state.query.to_lowercase(); + let filtered: Vec<&app::EnvironmentRow> = app.environments.iter().filter(|r| { + if q.is_empty() { return true; } + let mut hay = String::new(); + if let Some(l) = &r.label { hay.push_str(&l.to_lowercase()); hay.push(' '); } + hay.push_str(&r.id.to_lowercase()); + if let Some(h) = &r.repo_hints { hay.push(' '); hay.push_str(&h.to_lowercase()); } + hay.contains(&q) + }).collect(); + // Keep original order (already sorted) — no need to re-sort + let idx = state.selected; + if idx == 0 { app.env_filter = None; append_error_log("env.select: All"); } + else { + let env_idx = idx.saturating_sub(1); + if let Some(row) = filtered.get(env_idx) { + append_error_log(format!( + "env.select: id={} label={}", + row.id, + row.label.clone().unwrap_or_else(|| "".to_string()) + )); + app.env_filter = Some(row.id.clone()); + } + } + // If New Task page is open, reflect the new selection in its header immediately. + if let Some(page) = app.new_task.as_mut() { + page.env_id = app.env_filter.clone(); + } + // Trigger tasks refresh with the selected filter + app.status = "Loading tasks…".to_string(); + app.refresh_inflight = true; + app.list_generation = app.list_generation.saturating_add(1); + app.in_flight.clear(); + // reset spinner state + needs_redraw = true; + let backend = Arc::clone(&backend); + let tx = tx.clone(); + let env_sel = app.env_filter.clone(); + tokio::spawn(async move { + let res = app::load_tasks(&*backend, env_sel.as_deref()).await; + let _ = tx.send(app::AppEvent::TasksLoaded { env: env_sel, result: res }); + }); + } + } + _ => {} + } + } else { + // Base list view keys + match key.code { + KeyCode::Char('q') | KeyCode::Esc => { + break 0; + } + KeyCode::Down | KeyCode::Char('j') => { + app.next(); + needs_redraw = true; + } + KeyCode::Up | KeyCode::Char('k') => { + app.prev(); + needs_redraw = true; + } + // Ensure 'r' does not refresh tasks when the env modal is open. + KeyCode::Char('r') | KeyCode::Char('R') => { + if app.env_modal.is_some() { break 0; } + append_error_log(format!( + "refresh.request: env={}", + app.env_filter.clone().unwrap_or_else(|| "".to_string()) + )); + app.status = "Refreshing…".to_string(); + app.refresh_inflight = true; + app.list_generation = app.list_generation.saturating_add(1); + app.in_flight.clear(); + // reset spinner state + needs_redraw = true; + // Spawn background refresh + let backend = Arc::clone(&backend); + let tx = tx.clone(); + let env_sel = app.env_filter.clone(); + tokio::spawn(async move { + let res = app::load_tasks(&*backend, env_sel.as_deref()).await; + let _ = tx.send(app::AppEvent::TasksLoaded { env: env_sel, result: res }); + }); + } + KeyCode::Char('o') | KeyCode::Char('O') => { + app.env_modal = Some(app::EnvModalState { query: String::new(), selected: 0 }); + // Cache environments until user explicitly refreshes with 'r' inside the modal. + let should_fetch = app.environments.is_empty(); + if should_fetch { app.env_loading = true; app.env_error = None; } + needs_redraw = true; + if should_fetch { + let tx = tx.clone(); + tokio::spawn(async move { + let base_url = crate::util::normalize_base_url(&std::env::var("CODEX_CLOUD_TASKS_BASE_URL").unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string())); + let headers = crate::util::build_chatgpt_headers().await; + let res = crate::env_detect::list_environments(&base_url, &headers).await; + let _ = tx.send(app::AppEvent::EnvironmentsLoaded(res)); + }); + } + } + KeyCode::Char('n') => { + let env_opt = app.env_filter.clone(); + app.new_task = Some(crate::new_task::NewTaskPage::new(env_opt, app.best_of_n)); + app.status = "New Task: Enter to submit; Esc to cancel".to_string(); + needs_redraw = true; + } + KeyCode::Enter => { + if let Some(task) = app.tasks.get(app.selected).cloned() { + app.status = format!("Loading details for {title}…", title = task.title); + app.details_inflight = true; + // Open empty overlay immediately; content arrives via events + let overlay = app::DiffOverlay::new( + task.id.clone(), + task.title.clone(), + task.attempt_total, + ); + app.diff_overlay = Some(overlay); + needs_redraw = true; + // Spawn background details load (diff first, then messages fallback) + let id = task.id.clone(); + let title = task.title.clone(); + { + let backend = Arc::clone(&backend); + let tx = tx.clone(); + let diff_id = id.clone(); + let diff_title = title.clone(); + tokio::spawn(async move { + match codex_cloud_tasks_client::CloudBackend::get_task_diff(&*backend, diff_id.clone()).await { + Ok(Some(diff)) => { + let _ = tx.send(app::AppEvent::DetailsDiffLoaded { id: diff_id, title: diff_title, diff }); + } + Ok(None) => { + match codex_cloud_tasks_client::CloudBackend::get_task_text(&*backend, diff_id.clone()).await { + Ok(text) => { + let evt = app::AppEvent::DetailsMessagesLoaded { + id: diff_id, + title: diff_title, + messages: text.messages, + prompt: text.prompt, + turn_id: text.turn_id, + sibling_turn_ids: text.sibling_turn_ids, + attempt_placement: text.attempt_placement, + attempt_status: text.attempt_status, + }; + let _ = tx.send(evt); + } + Err(e2) => { + let _ = tx.send(app::AppEvent::DetailsFailed { id: diff_id, title: diff_title, error: format!("{e2}") }); + } + } + } + Err(e) => { + append_error_log(format!("get_task_diff failed for {}: {e}", diff_id.0)); + match codex_cloud_tasks_client::CloudBackend::get_task_text(&*backend, diff_id.clone()).await { + Ok(text) => { + let evt = app::AppEvent::DetailsMessagesLoaded { + id: diff_id, + title: diff_title, + messages: text.messages, + prompt: text.prompt, + turn_id: text.turn_id, + sibling_turn_ids: text.sibling_turn_ids, + attempt_placement: text.attempt_placement, + attempt_status: text.attempt_status, + }; + let _ = tx.send(evt); + } + Err(e2) => { + let _ = tx.send(app::AppEvent::DetailsFailed { id: diff_id, title: diff_title, error: format!("{e2}") }); + } + } + } + } + }); + } + // Also fetch conversation text even when diff exists + { + let backend = Arc::clone(&backend); + let tx = tx.clone(); + let msg_id = id; + let msg_title = title; + tokio::spawn(async move { + if let Ok(text) = codex_cloud_tasks_client::CloudBackend::get_task_text(&*backend, msg_id.clone()).await { + let evt = app::AppEvent::DetailsMessagesLoaded { + id: msg_id, + title: msg_title, + messages: text.messages, + prompt: text.prompt, + turn_id: text.turn_id, + sibling_turn_ids: text.sibling_turn_ids, + attempt_placement: text.attempt_placement, + attempt_status: text.attempt_status, + }; + let _ = tx.send(evt); + } + }); + } + // Animate spinner while details load. + let _ = frame_tx.send(Instant::now() + Duration::from_millis(100)); + } + } + KeyCode::Char('a') => { + if app.apply_inflight || app.apply_preflight_inflight { + app.status = "Finish the current apply/preflight before starting another.".to_string(); + needs_redraw = true; + continue; + } + + if let Some(task) = app.tasks.get(app.selected).cloned() { + match codex_cloud_tasks_client::CloudBackend::get_task_diff(&*backend, task.id.clone()).await { + Ok(Some(diff)) => { + let diff_override = Some(diff.clone()); + let task_id = task.id.clone(); + let title = task.title.clone(); + let job = ApplyJob { + task_id: task_id.clone(), + diff_override: diff_override.clone(), + }; + if spawn_preflight( + &mut app, + &backend, + &tx, + &frame_tx, + title.clone(), + job, + ) { + app.apply_modal = Some(app::ApplyModalState { + task_id, + title: title.clone(), + result_message: None, + result_level: None, + skipped_paths: Vec::new(), + conflict_paths: Vec::new(), + diff_override, + }); + app.status = format!("Preflighting '{title}'..."); + } + } + Ok(None) | Err(_) => { + app.status = "No diff available to apply".to_string(); + } + } + needs_redraw = true; + } + } + _ => {} + } + } + // Render after handling a key event (when not quitting). + render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?; + } + Some(Ok(Event::Resize(_, _))) => { + needs_redraw = true; + // Redraw immediately on resize for snappier UX. + render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?; + } + Some(Err(_)) | None => {} + _ => {} + } + // Fallback: if any other event path requested a redraw, render now. + render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?; + } + } + }; + + // Restore terminal + disable_raw_mode().ok(); + terminal.show_cursor().ok(); + let _ = crossterm::execute!(std::io::stdout(), DisableBracketedPaste); + // Best-effort restore of keyboard enhancement flags before leaving alt screen. + let _ = crossterm::execute!(std::io::stdout(), PopKeyboardEnhancementFlags); + let _ = crossterm::execute!(std::io::stdout(), LeaveAlternateScreen); + + if exit_code != 0 { + std::process::exit(exit_code); + } + Ok(()) +} + +// extract_chatgpt_account_id moved to util.rs + +/// Build plain-text conversation lines: a labeled user prompt followed by assistant messages. +fn conversation_lines(prompt: Option, messages: &[String]) -> Vec { + let mut out: Vec = Vec::new(); + if let Some(p) = prompt { + out.push("user:".to_string()); + for l in p.lines() { + out.push(l.to_string()); + } + out.push(String::new()); + } + if !messages.is_empty() { + out.push("assistant:".to_string()); + for (i, m) in messages.iter().enumerate() { + for l in m.lines() { + out.push(l.to_string()); + } + if i + 1 < messages.len() { + out.push(String::new()); + } + } + } + if out.is_empty() { + out.push("".to_string()); + } + out +} + +/// Convert a verbose HTTP error with embedded JSON body into concise, user-friendly lines +/// for the details overlay. Falls back to a short raw message when parsing fails. +fn pretty_lines_from_error(raw: &str) -> Vec { + let mut lines: Vec = Vec::new(); + let is_no_diff = raw.contains("No output_diff in response."); + let is_no_msgs = raw.contains("No assistant text messages in response."); + if is_no_diff { + lines.push("No diff available for this task.".to_string()); + } else if is_no_msgs { + lines.push("No assistant messages found for this task.".to_string()); + } else { + lines.push("Failed to load task details.".to_string()); + } + + // Try to parse the embedded JSON body: find the first '{' after " body=" and decode. + if let Some(body_idx) = raw.find(" body=") + && let Some(json_start_rel) = raw[body_idx..].find('{') + { + let json_start = body_idx + json_start_rel; + let json_str = raw[json_start..].trim(); + if let Ok(v) = serde_json::from_str::(json_str) { + // Prefer assistant turn context. + let turn = v + .get("current_assistant_turn") + .and_then(|x| x.as_object()) + .cloned() + .or_else(|| { + v.get("current_diff_task_turn") + .and_then(|x| x.as_object()) + .cloned() + }); + if let Some(t) = turn { + if let Some(err) = t.get("error").and_then(|e| e.as_object()) { + let code = err.get("code").and_then(|s| s.as_str()).unwrap_or(""); + let msg = err.get("message").and_then(|s| s.as_str()).unwrap_or(""); + if !code.is_empty() || !msg.is_empty() { + let summary = if code.is_empty() { + msg.to_string() + } else if msg.is_empty() { + code.to_string() + } else { + format!("{code}: {msg}") + }; + lines.push(format!("Assistant error: {summary}")); + } + } + if let Some(status) = t.get("turn_status").and_then(|s| s.as_str()) { + lines.push(format!("Status: {status}")); + } + if let Some(text) = t + .get("latest_event") + .and_then(|e| e.get("text")) + .and_then(|s| s.as_str()) + && !text.trim().is_empty() + { + lines.push(format!("Latest event: {}", text.trim())); + } + } + } + } + + if lines.len() == 1 { + // Parsing yielded nothing; include a trimmed, short raw message tail for context. + let tail = if raw.len() > 320 { + format!("{}…", &raw[..320]) + } else { + raw.to_string() + }; + lines.push(tail); + } else if lines.len() >= 2 { + // Add a hint to refresh when still in progress. + if lines.iter().any(|l| l.contains("in_progress")) { + lines.push("This task may still be running. Press 'r' to refresh.".to_string()); + } + // Avoid an empty overlay + lines.push(String::new()); + } + lines +} + +#[cfg(test)] +mod tests { + use codex_tui::ComposerAction; + use codex_tui::ComposerInput; + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + + #[test] + fn composer_input_renders_typed_characters() { + let mut composer = ComposerInput::new(); + let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE); + match composer.input(key) { + ComposerAction::Submitted(_) => panic!("unexpected submission"), + ComposerAction::None => {} + } + + let area = Rect::new(0, 0, 20, 5); + let mut buf = Buffer::empty(area); + composer.render_ref(area, &mut buf); + + let found = buf.content().iter().any(|cell| cell.symbol() == "a"); + assert!(found, "typed character was not rendered: {buf:?}"); + + composer.set_hint_items(vec![("⌃O", "env"), ("⌃C", "quit")]); + composer.render_ref(area, &mut buf); + let footer = buf + .content() + .iter() + .skip((area.width as usize) * (area.height as usize - 1)) + .map(ratatui::buffer::Cell::symbol) + .collect::>() + .join(""); + assert!(footer.contains("⌃O env")); + } +} diff --git a/codex-rs/cloud-tasks/src/new_task.rs b/codex-rs/cloud-tasks/src/new_task.rs new file mode 100644 index 00000000..162fd3bb --- /dev/null +++ b/codex-rs/cloud-tasks/src/new_task.rs @@ -0,0 +1,35 @@ +use codex_tui::ComposerInput; + +pub struct NewTaskPage { + pub composer: ComposerInput, + pub submitting: bool, + pub env_id: Option, + pub best_of_n: usize, +} + +impl NewTaskPage { + pub fn new(env_id: Option, best_of_n: usize) -> Self { + let mut composer = ComposerInput::new(); + composer.set_hint_items(vec![ + ("⏎", "send"), + ("Shift+⏎", "newline"), + ("Ctrl+O", "env"), + ("Ctrl+N", "attempts"), + ("Ctrl+C", "quit"), + ]); + Self { + composer, + submitting: false, + env_id, + best_of_n, + } + } + + // Additional helpers can be added as usage evolves. +} + +impl Default for NewTaskPage { + fn default() -> Self { + Self::new(None, 1) + } +} diff --git a/codex-rs/cloud-tasks/src/scrollable_diff.rs b/codex-rs/cloud-tasks/src/scrollable_diff.rs new file mode 100644 index 00000000..97dfb248 --- /dev/null +++ b/codex-rs/cloud-tasks/src/scrollable_diff.rs @@ -0,0 +1,176 @@ +use unicode_width::UnicodeWidthChar; +use unicode_width::UnicodeWidthStr; + +/// Scroll position and geometry for a vertical scroll view. +#[derive(Clone, Copy, Debug, Default)] +pub struct ScrollViewState { + pub scroll: u16, + pub viewport_h: u16, + pub content_h: u16, +} + +impl ScrollViewState { + pub fn clamp(&mut self) { + let max_scroll = self.content_h.saturating_sub(self.viewport_h); + if self.scroll > max_scroll { + self.scroll = max_scroll; + } + } +} + +/// A simple, local scrollable view for diffs or message text. +/// +/// Owns raw lines, caches wrapped lines for a given width, and maintains +/// a small scroll state that is clamped whenever geometry shrinks. +#[derive(Clone, Debug, Default)] +pub struct ScrollableDiff { + raw: Vec, + wrapped: Vec, + wrapped_src_idx: Vec, + wrap_cols: Option, + pub state: ScrollViewState, +} + +impl ScrollableDiff { + pub fn new() -> Self { + Self::default() + } + + /// Replace the raw content lines. Does not rewrap immediately; call `set_width` next. + pub fn set_content(&mut self, lines: Vec) { + self.raw = lines; + self.wrapped.clear(); + self.wrapped_src_idx.clear(); + self.state.content_h = 0; + // Force rewrap on next set_width even if width is unchanged + self.wrap_cols = None; + } + + /// Set the wrap width. If changed, rebuild wrapped lines and clamp scroll. + pub fn set_width(&mut self, width: u16) { + if self.wrap_cols == Some(width) { + return; + } + self.wrap_cols = Some(width); + self.rewrap(width); + self.state.clamp(); + } + + /// Update viewport height and clamp scroll if needed. + pub fn set_viewport(&mut self, height: u16) { + self.state.viewport_h = height; + self.state.clamp(); + } + + /// Return the cached wrapped lines. Call `set_width` first when area changes. + pub fn wrapped_lines(&self) -> &[String] { + &self.wrapped + } + + pub fn wrapped_src_indices(&self) -> &[usize] { + &self.wrapped_src_idx + } + + pub fn raw_line_at(&self, idx: usize) -> &str { + self.raw.get(idx).map(String::as_str).unwrap_or("") + } + + /// Scroll by a signed delta; clamps to content. + pub fn scroll_by(&mut self, delta: i16) { + let s = self.state.scroll as i32 + delta as i32; + self.state.scroll = s.clamp(0, self.max_scroll() as i32) as u16; + } + + /// Page by a signed delta; typically viewport_h - 1. + pub fn page_by(&mut self, delta: i16) { + self.scroll_by(delta); + } + + pub fn to_top(&mut self) { + self.state.scroll = 0; + } + + pub fn to_bottom(&mut self) { + self.state.scroll = self.max_scroll(); + } + + /// Optional percent scrolled; None when not enough geometry is known. + pub fn percent_scrolled(&self) -> Option { + if self.state.content_h == 0 || self.state.viewport_h == 0 { + return None; + } + if self.state.content_h <= self.state.viewport_h { + return None; + } + let visible_bottom = self.state.scroll.saturating_add(self.state.viewport_h) as f32; + let pct = (visible_bottom / self.state.content_h as f32 * 100.0).round(); + Some(pct.clamp(0.0, 100.0) as u8) + } + + fn max_scroll(&self) -> u16 { + self.state.content_h.saturating_sub(self.state.viewport_h) + } + + fn rewrap(&mut self, width: u16) { + if width == 0 { + self.wrapped = self.raw.clone(); + self.state.content_h = self.wrapped.len() as u16; + return; + } + let max_cols = width as usize; + let mut out: Vec = Vec::new(); + let mut out_idx: Vec = Vec::new(); + for (raw_idx, raw) in self.raw.iter().enumerate() { + // Normalize tabs for width accounting (MVP: 4 spaces). + let raw = raw.replace('\t', " "); + if raw.is_empty() { + out.push(String::new()); + out_idx.push(raw_idx); + continue; + } + let mut line = String::new(); + let mut line_cols = 0usize; + let mut last_soft_idx: Option = None; // last whitespace or punctuation break + for (_i, ch) in raw.char_indices() { + if ch == '\n' { + out.push(std::mem::take(&mut line)); + out_idx.push(raw_idx); + line_cols = 0; + last_soft_idx = None; + continue; + } + let w = UnicodeWidthChar::width(ch).unwrap_or(0); + if line_cols.saturating_add(w) > max_cols { + if let Some(split) = last_soft_idx { + let (prefix, rest) = line.split_at(split); + out.push(prefix.trim_end().to_string()); + out_idx.push(raw_idx); + line = rest.trim_start().to_string(); + last_soft_idx = None; + // retry add current ch now that line may be shorter + } else if !line.is_empty() { + out.push(std::mem::take(&mut line)); + out_idx.push(raw_idx); + } + } + if ch.is_whitespace() + || matches!( + ch, + ',' | ';' | '.' | ':' | ')' | ']' | '}' | '|' | '/' | '?' | '!' | '-' | '_' + ) + { + last_soft_idx = Some(line.len()); + } + line.push(ch); + line_cols = UnicodeWidthStr::width(line.as_str()); + } + if !line.is_empty() { + out.push(line); + out_idx.push(raw_idx); + } + } + self.wrapped = out; + self.wrapped_src_idx = out_idx; + self.state.content_h = self.wrapped.len() as u16; + } +} diff --git a/codex-rs/cloud-tasks/src/ui.rs b/codex-rs/cloud-tasks/src/ui.rs new file mode 100644 index 00000000..94fb3ad9 --- /dev/null +++ b/codex-rs/cloud-tasks/src/ui.rs @@ -0,0 +1,1048 @@ +use ratatui::layout::Constraint; +use ratatui::layout::Direction; +use ratatui::layout::Layout; +use ratatui::prelude::*; +use ratatui::style::Color; +use ratatui::style::Modifier; +use ratatui::style::Style; +use ratatui::style::Stylize; +use ratatui::widgets::Block; +use ratatui::widgets::BorderType; +use ratatui::widgets::Borders; +use ratatui::widgets::Clear; +use ratatui::widgets::List; +use ratatui::widgets::ListItem; +use ratatui::widgets::ListState; +use ratatui::widgets::Padding; +use ratatui::widgets::Paragraph; +use std::sync::OnceLock; + +use crate::app::App; +use crate::app::AttemptView; +use chrono::Local; +use chrono::Utc; +use codex_cloud_tasks_client::AttemptStatus; +use codex_cloud_tasks_client::TaskStatus; +use codex_tui::render_markdown_text; + +pub fn draw(frame: &mut Frame, app: &mut App) { + let area = frame.area(); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(1), // list + Constraint::Length(2), // two-line footer (help + status) + ]) + .split(area); + if app.new_task.is_some() { + draw_new_task_page(frame, chunks[0], app); + draw_footer(frame, chunks[1], app); + } else { + draw_list(frame, chunks[0], app); + draw_footer(frame, chunks[1], app); + } + + if app.diff_overlay.is_some() { + draw_diff_overlay(frame, area, app); + } + if app.env_modal.is_some() { + draw_env_modal(frame, area, app); + } + if app.best_of_modal.is_some() { + draw_best_of_modal(frame, area, app); + } + if app.apply_modal.is_some() { + draw_apply_modal(frame, area, app); + } +} + +// ===== Overlay helpers (geometry + styling) ===== +static ROUNDED: OnceLock = OnceLock::new(); + +fn rounded_enabled() -> bool { + *ROUNDED.get_or_init(|| { + std::env::var("CODEX_TUI_ROUNDED") + .ok() + .map(|v| v == "1") + .unwrap_or(true) + }) +} + +fn overlay_outer(area: Rect) -> Rect { + let outer_v = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(10), + Constraint::Percentage(80), + Constraint::Percentage(10), + ]) + .split(area)[1]; + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(10), + Constraint::Percentage(80), + Constraint::Percentage(10), + ]) + .split(outer_v)[1] +} + +fn overlay_block() -> Block<'static> { + let base = Block::default().borders(Borders::ALL); + let base = if rounded_enabled() { + base.border_type(BorderType::Rounded) + } else { + base + }; + base.padding(Padding::new(2, 2, 1, 1)) +} + +fn overlay_content(area: Rect) -> Rect { + overlay_block().inner(area) +} + +pub fn draw_new_task_page(frame: &mut Frame, area: Rect, app: &mut App) { + let title_spans = { + let mut spans: Vec = vec!["New Task".magenta().bold()]; + if let Some(id) = app + .new_task + .as_ref() + .and_then(|p| p.env_id.as_ref()) + .cloned() + { + spans.push(" • ".into()); + // Try to map id to label + let label = app + .environments + .iter() + .find(|r| r.id == id) + .and_then(|r| r.label.clone()) + .unwrap_or(id); + spans.push(label.dim()); + } else { + spans.push(" • ".into()); + spans.push("Env: none (press ctrl-o to choose)".red()); + } + if let Some(page) = app.new_task.as_ref() { + spans.push(" • ".into()); + let attempts = page.best_of_n; + let label = format!( + "{} attempt{}", + attempts, + if attempts == 1 { "" } else { "s" } + ); + spans.push(label.cyan()); + } + spans + }; + let block = Block::default() + .borders(Borders::ALL) + .title(Line::from(title_spans)); + + frame.render_widget(Clear, area); + frame.render_widget(block.clone(), area); + let content = block.inner(area); + + // Expand composer height up to (terminal height - 6), with a 3-line minimum. + let max_allowed = frame.area().height.saturating_sub(6).max(3); + let desired = app + .new_task + .as_ref() + .map(|p| p.composer.desired_height(content.width)) + .unwrap_or(3) + .clamp(3, max_allowed); + + // Anchor the composer to the bottom-left by allocating a flexible spacer + // above it and a fixed `desired`-height area for the composer. + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Length(desired)]) + .split(content); + let composer_area = rows[1]; + + if let Some(page) = app.new_task.as_ref() { + page.composer.render_ref(composer_area, frame.buffer_mut()); + // Composer renders its own footer hints; no extra row here. + } + + // Place cursor where composer wants it + if let Some(page) = app.new_task.as_ref() + && let Some((x, y)) = page.composer.cursor_pos(composer_area) + { + frame.set_cursor_position((x, y)); + } +} + +fn draw_list(frame: &mut Frame, area: Rect, app: &mut App) { + let items: Vec = app.tasks.iter().map(|t| render_task_item(app, t)).collect(); + + // Selection reflects the actual task index (no artificial spacer item). + let mut state = ListState::default().with_selected(Some(app.selected)); + // Dim task list when a modal/overlay is active to emphasize focus. + let dim_bg = app.env_modal.is_some() + || app.apply_modal.is_some() + || app.best_of_modal.is_some() + || app.diff_overlay.is_some(); + // Dynamic title includes current environment filter + let suffix_span = if let Some(ref id) = app.env_filter { + let label = app + .environments + .iter() + .find(|r| &r.id == id) + .and_then(|r| r.label.clone()) + .unwrap_or_else(|| "Selected".to_string()); + format!(" • {label}").dim() + } else { + " • All".dim() + }; + // Percent scrolled based on selection position in the list (0% at top, 100% at bottom). + let percent_span = if app.tasks.len() <= 1 { + " • 0%".dim() + } else { + let p = ((app.selected as f32) / ((app.tasks.len() - 1) as f32) * 100.0).round() as i32; + format!(" • {}%", p.clamp(0, 100)).dim() + }; + let title_line = { + let base = Line::from(vec!["Cloud Tasks".into(), suffix_span, percent_span]); + if dim_bg { + base.style(Style::default().add_modifier(Modifier::DIM)) + } else { + base + } + }; + let block = Block::default().borders(Borders::ALL).title(title_line); + // Render the outer block first + frame.render_widget(block.clone(), area); + // Draw list inside with a persistent top spacer row + let inner = block.inner(area); + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Min(1)]) + .split(inner); + let mut list = List::new(items) + .highlight_symbol("› ") + .highlight_style(Style::default().bold()); + if dim_bg { + list = list.style(Style::default().add_modifier(Modifier::DIM)); + } + frame.render_stateful_widget(list, rows[1], &mut state); + + // In-box spinner during initial/refresh loads + if app.refresh_inflight { + draw_centered_spinner(frame, inner, &mut app.throbber, "Loading tasks…"); + } +} + +fn draw_footer(frame: &mut Frame, area: Rect, app: &mut App) { + let mut help = vec![ + "↑/↓".dim(), + ": Move ".dim(), + "r".dim(), + ": Refresh ".dim(), + "Enter".dim(), + ": Open ".dim(), + ]; + // Apply hint; show disabled note when overlay is open without a diff. + if let Some(ov) = app.diff_overlay.as_ref() { + if !ov.current_can_apply() { + help.push("a".dim()); + help.push(": Apply (disabled) ".dim()); + } else { + help.push("a".dim()); + help.push(": Apply ".dim()); + } + if ov.attempt_count() > 1 { + help.push("Tab".dim()); + help.push(": Next attempt ".dim()); + help.push("[ ]".dim()); + help.push(": Cycle attempts ".dim()); + } + } else { + help.push("a".dim()); + help.push(": Apply ".dim()); + } + help.push("o : Set Env ".dim()); + help.push("Ctrl+N".dim()); + help.push(format!(": Attempts {}x ", app.best_of_n).dim()); + if app.new_task.is_some() { + help.push("(editing new task) ".dim()); + } else { + help.push("n : New Task ".dim()); + } + help.extend(vec!["q".dim(), ": Quit ".dim()]); + // Split footer area into two rows: help+spinner (top) and status (bottom) + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Length(1)]) + .split(area); + + // Top row: help text + spinner at right + let top = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Fill(1), Constraint::Length(18)]) + .split(rows[0]); + let para = Paragraph::new(Line::from(help)); + // Draw help text; avoid clearing the whole footer area every frame. + frame.render_widget(para, top[0]); + // Right side: spinner or clear the spinner area if idle to prevent stale glyphs. + if app.refresh_inflight + || app.details_inflight + || app.env_loading + || app.apply_preflight_inflight + || app.apply_inflight + { + draw_inline_spinner(frame, top[1], &mut app.throbber, "Loading…"); + } else { + frame.render_widget(Clear, top[1]); + } + + // Bottom row: status/log text across full width (single-line; sanitize newlines) + let mut status_line = app.status.replace('\n', " "); + if status_line.len() > 2000 { + // hard cap to avoid TUI noise + status_line.truncate(2000); + status_line.push('…'); + } + // Clear the status row to avoid trailing characters when the message shrinks. + frame.render_widget(Clear, rows[1]); + let status = Paragraph::new(status_line); + frame.render_widget(status, rows[1]); +} + +fn draw_diff_overlay(frame: &mut Frame, area: Rect, app: &mut App) { + let inner = overlay_outer(area); + if app.diff_overlay.is_none() { + return; + } + let ov_can_apply = app + .diff_overlay + .as_ref() + .map(super::app::DiffOverlay::current_can_apply) + .unwrap_or(false); + let is_error = app + .diff_overlay + .as_ref() + .and_then(|o| o.sd.wrapped_lines().first().cloned()) + .map(|s| s.trim_start().starts_with("Task failed:")) + .unwrap_or(false) + && !ov_can_apply; + let title = app + .diff_overlay + .as_ref() + .map(|o| o.title.clone()) + .unwrap_or_default(); + + // Title block + let title_ref = title.as_str(); + let mut title_spans: Vec = if is_error { + vec![ + "Details ".magenta(), + "[FAILED]".red().bold(), + " ".into(), + title_ref.magenta(), + ] + } else if ov_can_apply { + vec!["Diff: ".magenta(), title_ref.magenta()] + } else { + vec!["Details: ".magenta(), title_ref.magenta()] + }; + if let Some(p) = app + .diff_overlay + .as_ref() + .and_then(|o| o.sd.percent_scrolled()) + { + title_spans.push(" • ".dim()); + title_spans.push(format!("{p}%").dim()); + } + frame.render_widget(Clear, inner); + frame.render_widget( + overlay_block().title(Line::from(title_spans)).clone(), + inner, + ); + + // Content area and optional status bar + let content_full = overlay_content(inner); + let mut content_area = content_full; + if let Some(ov) = app.diff_overlay.as_mut() { + let has_text = ov.current_attempt().is_some_and(AttemptView::has_text); + let has_diff = ov.current_attempt().is_some_and(AttemptView::has_diff) || ov.base_can_apply; + if has_diff || has_text { + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Min(1)]) + .split(content_full); + // Status bar label + let mut spans: Vec = Vec::new(); + if has_diff && has_text { + let prompt_lbl = if matches!(ov.current_view, crate::app::DetailView::Prompt) { + "[Prompt]".magenta().bold() + } else { + "Prompt".dim() + }; + let diff_lbl = if matches!(ov.current_view, crate::app::DetailView::Diff) { + "[Diff]".magenta().bold() + } else { + "Diff".dim() + }; + spans.extend(vec![ + prompt_lbl, + " ".into(), + diff_lbl, + " ".into(), + "(← → to switch view)".dim(), + ]); + } else if has_text { + spans.push("Conversation".magenta().bold()); + } else { + spans.push("Diff".magenta().bold()); + } + if let Some(total) = ov.expected_attempts().or({ + if ov.attempts.is_empty() { + None + } else { + Some(ov.attempts.len()) + } + }) && total > 1 + { + spans.extend(vec![ + " ".into(), + format!("Attempt {}/{}", ov.selected_attempt + 1, total) + .bold() + .dim(), + " ".into(), + "(Tab/Shift-Tab or [ ] to cycle attempts)".dim(), + ]); + } + frame.render_widget(Paragraph::new(Line::from(spans)), rows[0]); + ov.sd.set_width(rows[1].width); + ov.sd.set_viewport(rows[1].height); + content_area = rows[1]; + } else { + ov.sd.set_width(content_full.width); + ov.sd.set_viewport(content_full.height); + content_area = content_full; + } + } + + // Styled content render + // Choose styling by the active view, not just presence of a diff + let is_diff_view = app + .diff_overlay + .as_ref() + .map(|o| matches!(o.current_view, crate::app::DetailView::Diff)) + .unwrap_or(false); + let styled_lines: Vec> = if is_diff_view { + let raw = app.diff_overlay.as_ref().map(|o| o.sd.wrapped_lines()); + raw.unwrap_or(&[]) + .iter() + .map(|l| style_diff_line(l)) + .collect() + } else { + app.diff_overlay + .as_ref() + .map(|o| style_conversation_lines(&o.sd, o.current_attempt())) + .unwrap_or_default() + }; + let raw_empty = app + .diff_overlay + .as_ref() + .map(|o| o.sd.wrapped_lines().is_empty()) + .unwrap_or(true); + if app.details_inflight && raw_empty { + draw_centered_spinner(frame, content_area, &mut app.throbber, "Loading details…"); + } else { + let scroll = app + .diff_overlay + .as_ref() + .map(|o| o.sd.state.scroll) + .unwrap_or(0); + let content = Paragraph::new(Text::from(styled_lines)).scroll((scroll, 0)); + frame.render_widget(content, content_area); + } +} + +pub fn draw_apply_modal(frame: &mut Frame, area: Rect, app: &mut App) { + use ratatui::widgets::Wrap; + let inner = overlay_outer(area); + let title = Line::from("Apply Changes?".magenta().bold()); + let block = overlay_block().title(title); + frame.render_widget(Clear, inner); + frame.render_widget(block.clone(), inner); + let content = overlay_content(inner); + + if let Some(m) = &app.apply_modal { + // Header + let header = Paragraph::new(Line::from( + format!("Apply '{}' ?", m.title).magenta().bold(), + )) + .wrap(Wrap { trim: true }); + // Footer instructions + let footer = + Paragraph::new(Line::from("Press Y to apply, P to preflight, N to cancel.").dim()) + .wrap(Wrap { trim: true }); + + // Split into header/body/footer + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(1), + ]) + .split(content); + + frame.render_widget(header, rows[0]); + // Body: spinner while preflight/apply runs; otherwise show result message and path lists + if app.apply_preflight_inflight { + draw_centered_spinner(frame, rows[1], &mut app.throbber, "Checking…"); + } else if app.apply_inflight { + draw_centered_spinner(frame, rows[1], &mut app.throbber, "Applying…"); + } else if m.result_message.is_none() { + draw_centered_spinner(frame, rows[1], &mut app.throbber, "Loading…"); + } else if let Some(msg) = &m.result_message { + let mut body_lines: Vec = Vec::new(); + let first = match m.result_level { + Some(crate::app::ApplyResultLevel::Success) => msg.clone().green(), + Some(crate::app::ApplyResultLevel::Partial) => msg.clone().magenta(), + Some(crate::app::ApplyResultLevel::Error) => msg.clone().red(), + None => msg.clone().into(), + }; + body_lines.push(Line::from(first)); + + // On partial or error, show conflicts/skips if present + if !matches!(m.result_level, Some(crate::app::ApplyResultLevel::Success)) { + use ratatui::text::Span; + if !m.conflict_paths.is_empty() { + body_lines.push(Line::from("")); + body_lines.push( + Line::from(format!("Conflicts ({}):", m.conflict_paths.len())) + .red() + .bold(), + ); + for p in &m.conflict_paths { + body_lines + .push(Line::from(vec![" • ".into(), Span::raw(p.clone()).dim()])); + } + } + if !m.skipped_paths.is_empty() { + body_lines.push(Line::from("")); + body_lines.push( + Line::from(format!("Skipped ({}):", m.skipped_paths.len())) + .magenta() + .bold(), + ); + for p in &m.skipped_paths { + body_lines + .push(Line::from(vec![" • ".into(), Span::raw(p.clone()).dim()])); + } + } + } + let body = Paragraph::new(body_lines).wrap(Wrap { trim: true }); + frame.render_widget(body, rows[1]); + } + frame.render_widget(footer, rows[2]); + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ConversationSpeaker { + User, + Assistant, +} + +fn style_conversation_lines( + sd: &crate::scrollable_diff::ScrollableDiff, + attempt: Option<&AttemptView>, +) -> Vec> { + use ratatui::text::Span; + + let wrapped = sd.wrapped_lines(); + if wrapped.is_empty() { + return Vec::new(); + } + + let indices = sd.wrapped_src_indices(); + let mut styled: Vec> = Vec::new(); + let mut speaker: Option = None; + let mut in_code = false; + let mut last_src: Option = None; + let mut bullet_indent: Option = None; + + for (display, &src_idx) in wrapped.iter().zip(indices.iter()) { + let raw = sd.raw_line_at(src_idx); + let trimmed = raw.trim(); + let is_new_raw = last_src.map(|prev| prev != src_idx).unwrap_or(true); + + if trimmed.eq_ignore_ascii_case("user:") { + speaker = Some(ConversationSpeaker::User); + in_code = false; + bullet_indent = None; + styled.push(conversation_header_line(ConversationSpeaker::User, None)); + last_src = Some(src_idx); + continue; + } + if trimmed.eq_ignore_ascii_case("assistant:") { + speaker = Some(ConversationSpeaker::Assistant); + in_code = false; + bullet_indent = None; + styled.push(conversation_header_line( + ConversationSpeaker::Assistant, + attempt, + )); + last_src = Some(src_idx); + continue; + } + if raw.is_empty() { + let mut spans: Vec = Vec::new(); + if let Some(role) = speaker { + spans.push(conversation_gutter_span(role)); + } else { + spans.push(Span::raw(String::new())); + } + styled.push(Line::from(spans)); + last_src = Some(src_idx); + bullet_indent = None; + continue; + } + + if is_new_raw { + let trimmed_start = raw.trim_start(); + if trimmed_start.starts_with("```") { + in_code = !in_code; + bullet_indent = None; + } else if !in_code + && (trimmed_start.starts_with("- ") || trimmed_start.starts_with("* ")) + { + let indent = raw.chars().take_while(|c| c.is_whitespace()).count(); + bullet_indent = Some(indent); + } else if !in_code { + bullet_indent = None; + } + } + + let mut spans: Vec = Vec::new(); + if let Some(role) = speaker { + spans.push(conversation_gutter_span(role)); + } + + spans.extend(conversation_text_spans( + display, + in_code, + is_new_raw, + bullet_indent, + )); + + styled.push(Line::from(spans)); + last_src = Some(src_idx); + } + + if styled.is_empty() { + wrapped.iter().map(|l| Line::from(l.to_string())).collect() + } else { + styled + } +} + +fn conversation_header_line( + speaker: ConversationSpeaker, + attempt: Option<&AttemptView>, +) -> Line<'static> { + use ratatui::text::Span; + + let mut spans: Vec = vec!["╭ ".dim()]; + match speaker { + ConversationSpeaker::User => { + spans.push("User".cyan().bold()); + spans.push(" prompt".dim()); + } + ConversationSpeaker::Assistant => { + spans.push("Assistant".magenta().bold()); + spans.push(" response".dim()); + if let Some(attempt) = attempt + && let Some(status_span) = attempt_status_span(attempt.status) + { + spans.push(" • ".dim()); + spans.push(status_span); + } + } + } + Line::from(spans) +} + +fn conversation_gutter_span(speaker: ConversationSpeaker) -> ratatui::text::Span<'static> { + match speaker { + ConversationSpeaker::User => "│ ".cyan().dim(), + ConversationSpeaker::Assistant => "│ ".magenta().dim(), + } +} + +fn conversation_text_spans( + display: &str, + in_code: bool, + is_new_raw: bool, + bullet_indent: Option, +) -> Vec> { + use ratatui::text::Span; + + if in_code { + return vec![Span::styled( + display.to_string(), + Style::default().fg(Color::Cyan), + )]; + } + + let trimmed = display.trim_start(); + + if let Some(indent) = bullet_indent { + if is_new_raw { + let rest = trimmed.get(2..).unwrap_or("").trim_start(); + let mut spans: Vec = Vec::new(); + if indent > 0 { + spans.push(Span::raw(" ".repeat(indent))); + } + spans.push("• ".into()); + spans.push(Span::raw(rest.to_string())); + return spans; + } + let mut continuation = String::new(); + continuation.push_str(&" ".repeat(indent + 2)); + continuation.push_str(trimmed); + return vec![Span::raw(continuation)]; + } + + if is_new_raw + && (trimmed.starts_with("### ") || trimmed.starts_with("## ") || trimmed.starts_with("# ")) + { + return vec![Span::styled( + display.to_string(), + Style::default() + .fg(Color::Magenta) + .add_modifier(Modifier::BOLD), + )]; + } + + let mut rendered = render_markdown_text(display); + if rendered.lines.is_empty() { + return vec![Span::raw(display.to_string())]; + } + // `render_markdown_text` can yield multiple lines when the input contains + // explicit breaks. We only expect a single line here; join the spans of the + // first rendered line for styling. + rendered.lines.remove(0).spans.into_iter().collect() +} + +fn attempt_status_span(status: AttemptStatus) -> Option> { + match status { + AttemptStatus::Completed => Some("Completed".green()), + AttemptStatus::Failed => Some("Failed".red().bold()), + AttemptStatus::InProgress => Some("In progress".magenta()), + AttemptStatus::Pending => Some("Pending".cyan()), + AttemptStatus::Cancelled => Some("Cancelled".dim()), + AttemptStatus::Unknown => None, + } +} + +fn style_diff_line(raw: &str) -> Line<'static> { + use ratatui::style::Color; + use ratatui::style::Modifier; + use ratatui::style::Style; + use ratatui::text::Span; + + if raw.starts_with("@@") { + return Line::from(vec![Span::styled( + raw.to_string(), + Style::default() + .fg(Color::Magenta) + .add_modifier(Modifier::BOLD), + )]); + } + if raw.starts_with("+++") || raw.starts_with("---") { + return Line::from(vec![Span::styled( + raw.to_string(), + Style::default().add_modifier(Modifier::DIM), + )]); + } + if raw.starts_with('+') { + return Line::from(vec![Span::styled( + raw.to_string(), + Style::default().fg(Color::Green), + )]); + } + if raw.starts_with('-') { + return Line::from(vec![Span::styled( + raw.to_string(), + Style::default().fg(Color::Red), + )]); + } + Line::from(vec![Span::raw(raw.to_string())]) +} + +fn render_task_item(_app: &App, t: &codex_cloud_tasks_client::TaskSummary) -> ListItem<'static> { + let status = match t.status { + TaskStatus::Ready => "READY".green(), + TaskStatus::Pending => "PENDING".magenta(), + TaskStatus::Applied => "APPLIED".blue(), + TaskStatus::Error => "ERROR".red(), + }; + + // Title line: [STATUS] Title + let title = Line::from(vec![ + "[".into(), + status, + "] ".into(), + t.title.clone().into(), + ]); + + // Meta line: environment label and relative time (dim) + let mut meta: Vec = Vec::new(); + if let Some(lbl) = t.environment_label.as_ref().filter(|s| !s.is_empty()) { + meta.push(lbl.clone().dim()); + } + let when = format_relative_time(t.updated_at).dim(); + if !meta.is_empty() { + meta.push(" ".into()); + meta.push("•".dim()); + meta.push(" ".into()); + } + meta.push(when); + let meta_line = Line::from(meta); + + // Subline: summary when present; otherwise show "no diff" + let sub = if t.summary.files_changed > 0 + || t.summary.lines_added > 0 + || t.summary.lines_removed > 0 + { + let adds = t.summary.lines_added; + let dels = t.summary.lines_removed; + let files = t.summary.files_changed; + Line::from(vec![ + format!("+{adds}").green(), + "/".into(), + format!("−{dels}").red(), + " ".into(), + "•".dim(), + " ".into(), + format!("{files}").into(), + " ".into(), + "files".dim(), + ]) + } else { + Line::from("no diff".to_string().dim()) + }; + + // Insert a blank spacer line after the summary to separate tasks + let spacer = Line::from(""); + ListItem::new(vec![title, meta_line, sub, spacer]) +} + +fn format_relative_time(ts: chrono::DateTime) -> String { + let now = Utc::now(); + let mut secs = (now - ts).num_seconds(); + if secs < 0 { + secs = 0; + } + if secs < 60 { + return format!("{secs}s ago"); + } + let mins = secs / 60; + if mins < 60 { + return format!("{mins}m ago"); + } + let hours = mins / 60; + if hours < 24 { + return format!("{hours}h ago"); + } + let local = ts.with_timezone(&Local); + local.format("%b %e %H:%M").to_string() +} + +fn draw_inline_spinner( + frame: &mut Frame, + area: Rect, + state: &mut throbber_widgets_tui::ThrobberState, + label: &str, +) { + use ratatui::style::Style; + use throbber_widgets_tui::BRAILLE_EIGHT; + use throbber_widgets_tui::Throbber; + use throbber_widgets_tui::WhichUse; + let w = Throbber::default() + .label(label) + .style(Style::default().cyan()) + .throbber_style(Style::default().magenta().bold()) + .throbber_set(BRAILLE_EIGHT) + .use_type(WhichUse::Spin); + frame.render_stateful_widget(w, area, state); +} + +fn draw_centered_spinner( + frame: &mut Frame, + area: Rect, + state: &mut throbber_widgets_tui::ThrobberState, + label: &str, +) { + // Center a 1xN throbber within the given rect + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(50), + Constraint::Length(1), + Constraint::Percentage(49), + ]) + .split(area); + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(50), + Constraint::Length(18), + Constraint::Percentage(50), + ]) + .split(rows[1]); + draw_inline_spinner(frame, cols[1], state, label); +} + +// Styling helpers for diff rendering live inline where used. + +pub fn draw_env_modal(frame: &mut Frame, area: Rect, app: &mut App) { + use ratatui::widgets::Wrap; + + // Use shared overlay geometry and padding. + let inner = overlay_outer(area); + + // Title: primary only; move long hints to a subheader inside content. + let title = Line::from(vec!["Select Environment".magenta().bold()]); + let block = overlay_block().title(title); + + frame.render_widget(Clear, inner); + frame.render_widget(block.clone(), inner); + let content = overlay_content(inner); + + if app.env_loading { + draw_centered_spinner(frame, content, &mut app.throbber, "Loading environments…"); + return; + } + + // Layout: subheader + search + results list + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // subheader + Constraint::Length(1), // search + Constraint::Min(1), // list + ]) + .split(content); + + // Subheader with usage hints (dim cyan) + let subheader = Paragraph::new(Line::from( + "Type to search, Enter select, Esc cancel; r refresh" + .cyan() + .dim(), + )) + .wrap(Wrap { trim: true }); + frame.render_widget(subheader, rows[0]); + + let query = app + .env_modal + .as_ref() + .map(|m| m.query.clone()) + .unwrap_or_default(); + let ql = query.to_lowercase(); + let search = Paragraph::new(format!("Search: {query}")).wrap(Wrap { trim: true }); + frame.render_widget(search, rows[1]); + + // Filter environments by query (case-insensitive substring over label/id/hints) + let envs: Vec<&crate::app::EnvironmentRow> = app + .environments + .iter() + .filter(|e| { + if ql.is_empty() { + return true; + } + let mut hay = String::new(); + if let Some(l) = &e.label { + hay.push_str(&l.to_lowercase()); + hay.push(' '); + } + hay.push_str(&e.id.to_lowercase()); + if let Some(h) = &e.repo_hints { + hay.push(' '); + hay.push_str(&h.to_lowercase()); + } + hay.contains(&ql) + }) + .collect(); + + let mut items: Vec = Vec::new(); + items.push(ListItem::new(Line::from("All Environments (Global)"))); + for env in envs.iter() { + let primary = env.label.clone().unwrap_or_else(|| "".to_string()); + let mut spans: Vec = vec![primary.into()]; + if env.is_pinned { + spans.push(" ".into()); + spans.push("PINNED".magenta().bold()); + } + spans.push(" ".into()); + spans.push(env.id.clone().dim()); + if let Some(hint) = &env.repo_hints { + spans.push(" ".into()); + spans.push(hint.clone().dim()); + } + items.push(ListItem::new(Line::from(spans))); + } + + let sel_desired = app.env_modal.as_ref().map(|m| m.selected).unwrap_or(0); + let sel = sel_desired.min(envs.len()); + let mut list_state = ListState::default().with_selected(Some(sel)); + let list = List::new(items) + .highlight_symbol("› ") + .highlight_style(Style::default().bold()) + .block(Block::default().borders(Borders::NONE)); + frame.render_stateful_widget(list, rows[2], &mut list_state); +} + +pub fn draw_best_of_modal(frame: &mut Frame, area: Rect, app: &mut App) { + use ratatui::widgets::Wrap; + + let inner = overlay_outer(area); + let title = Line::from(vec!["Parallel Attempts".magenta().bold()]); + let block = overlay_block().title(title); + + frame.render_widget(Clear, inner); + frame.render_widget(block.clone(), inner); + let content = overlay_content(inner); + + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(2), Constraint::Min(1)]) + .split(content); + + let hint = Paragraph::new(Line::from( + "Use ↑/↓ to choose, 1-4 jump; Enter confirm, Esc cancel" + .cyan() + .dim(), + )) + .wrap(Wrap { trim: true }); + frame.render_widget(hint, rows[0]); + + let selected = app.best_of_modal.as_ref().map(|m| m.selected).unwrap_or(0); + let options = [1usize, 2, 3, 4]; + let mut items: Vec = Vec::new(); + for &attempts in &options { + let mut spans: Vec = + vec![format!("{attempts} attempt{}", if attempts == 1 { "" } else { "s" }).into()]; + spans.push(" ".into()); + spans.push(format!("{attempts}x parallel").dim()); + if attempts == app.best_of_n { + spans.push(" ".into()); + spans.push("Current".magenta().bold()); + } + items.push(ListItem::new(Line::from(spans))); + } + let sel = selected.min(options.len().saturating_sub(1)); + let mut list_state = ListState::default().with_selected(Some(sel)); + let list = List::new(items) + .highlight_symbol("› ") + .highlight_style(Style::default().bold()) + .block(Block::default().borders(Borders::NONE)); + frame.render_stateful_widget(list, rows[1], &mut list_state); +} diff --git a/codex-rs/cloud-tasks/src/util.rs b/codex-rs/cloud-tasks/src/util.rs new file mode 100644 index 00000000..9a610da2 --- /dev/null +++ b/codex-rs/cloud-tasks/src/util.rs @@ -0,0 +1,93 @@ +use base64::Engine as _; +use chrono::Utc; +use reqwest::header::HeaderMap; + +pub fn set_user_agent_suffix(suffix: &str) { + if let Ok(mut guard) = codex_core::default_client::USER_AGENT_SUFFIX.lock() { + guard.replace(suffix.to_string()); + } +} + +pub fn append_error_log(message: impl AsRef) { + let ts = Utc::now().to_rfc3339(); + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open("error.log") + { + use std::io::Write as _; + let _ = writeln!(f, "[{ts}] {}", message.as_ref()); + } +} + +/// Normalize the configured base URL to a canonical form used by the backend client. +/// - trims trailing '/' +/// - appends '/backend-api' for ChatGPT hosts when missing +pub fn normalize_base_url(input: &str) -> String { + let mut base_url = input.to_string(); + while base_url.ends_with('/') { + base_url.pop(); + } + if (base_url.starts_with("https://chatgpt.com") + || base_url.starts_with("https://chat.openai.com")) + && !base_url.contains("/backend-api") + { + base_url = format!("{base_url}/backend-api"); + } + base_url +} + +/// Extract the ChatGPT account id from a JWT token, when present. +pub fn extract_chatgpt_account_id(token: &str) -> Option { + let mut parts = token.split('.'); + let (_h, payload_b64, _s) = match (parts.next(), parts.next(), parts.next()) { + (Some(h), Some(p), Some(s)) if !h.is_empty() && !p.is_empty() && !s.is_empty() => (h, p, s), + _ => return None, + }; + let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload_b64) + .ok()?; + let v: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?; + v.get("https://api.openai.com/auth") + .and_then(|auth| auth.get("chatgpt_account_id")) + .and_then(|id| id.as_str()) + .map(str::to_string) +} + +/// Build headers for ChatGPT-backed requests: `User-Agent`, optional `Authorization`, +/// and optional `ChatGPT-Account-Id`. +pub async fn build_chatgpt_headers() -> HeaderMap { + use reqwest::header::AUTHORIZATION; + use reqwest::header::HeaderName; + use reqwest::header::HeaderValue; + use reqwest::header::USER_AGENT; + + set_user_agent_suffix("codex_cloud_tasks_tui"); + let ua = codex_core::default_client::get_codex_user_agent(); + let mut headers = HeaderMap::new(); + headers.insert( + USER_AGENT, + HeaderValue::from_str(&ua).unwrap_or(HeaderValue::from_static("codex-cli")), + ); + if let Ok(home) = codex_core::config::find_codex_home() { + let am = codex_login::AuthManager::new(home); + if let Some(auth) = am.auth() + && let Ok(tok) = auth.get_token().await + && !tok.is_empty() + { + let v = format!("Bearer {tok}"); + if let Ok(hv) = HeaderValue::from_str(&v) { + headers.insert(AUTHORIZATION, hv); + } + if let Some(acc) = auth + .get_account_id() + .or_else(|| extract_chatgpt_account_id(&tok)) + && let Ok(name) = HeaderName::from_bytes(b"ChatGPT-Account-Id") + && let Ok(hv) = HeaderValue::from_str(&acc) + { + headers.insert(name, hv); + } + } + } + headers +} diff --git a/codex-rs/cloud-tasks/tests/env_filter.rs b/codex-rs/cloud-tasks/tests/env_filter.rs new file mode 100644 index 00000000..8c737c6c --- /dev/null +++ b/codex-rs/cloud-tasks/tests/env_filter.rs @@ -0,0 +1,22 @@ +use codex_cloud_tasks_client::CloudBackend; +use codex_cloud_tasks_client::MockClient; + +#[tokio::test] +async fn mock_backend_varies_by_env() { + let client = MockClient; + + let root = CloudBackend::list_tasks(&client, None).await.unwrap(); + assert!(root.iter().any(|t| t.title.contains("Update README"))); + + let a = CloudBackend::list_tasks(&client, Some("env-A")) + .await + .unwrap(); + assert_eq!(a.len(), 1); + assert_eq!(a[0].title, "A: First"); + + let b = CloudBackend::list_tasks(&client, Some("env-B")) + .await + .unwrap(); + assert_eq!(b.len(), 2); + assert!(b[0].title.starts_with("B: ")); +} diff --git a/codex-rs/codex-backend-openapi-models/Cargo.toml b/codex-rs/codex-backend-openapi-models/Cargo.toml new file mode 100644 index 00000000..811ee72d --- /dev/null +++ b/codex-rs/codex-backend-openapi-models/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "codex-backend-openapi-models" +version = { workspace = true } +edition = "2024" + +[lib] +name = "codex_backend_openapi_models" +path = "src/lib.rs" + +# Important: generated code often violates our workspace lints. +# Allow unwrap/expect in this crate so the workspace builds cleanly +# after models are regenerated. +# Lint overrides are applied in src/lib.rs via crate attributes + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/codex-rs/codex-backend-openapi-models/src/lib.rs b/codex-rs/codex-backend-openapi-models/src/lib.rs new file mode 100644 index 00000000..f9e6d52f --- /dev/null +++ b/codex-rs/codex-backend-openapi-models/src/lib.rs @@ -0,0 +1,6 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] + +// Re-export generated OpenAPI models. +// The regen script populates `src/models/*.rs` and writes `src/models/mod.rs`. +// This module intentionally contains no hand-written types. +pub mod models; diff --git a/codex-rs/codex-backend-openapi-models/src/models/code_task_details_response.rs b/codex-rs/codex-backend-openapi-models/src/models/code_task_details_response.rs new file mode 100644 index 00000000..725b3a37 --- /dev/null +++ b/codex-rs/codex-backend-openapi-models/src/models/code_task_details_response.rs @@ -0,0 +1,42 @@ +/* + * codex-backend + * + * codex-backend + * + * The version of the OpenAPI document: 0.0.1 + * + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct CodeTaskDetailsResponse { + #[serde(rename = "task")] + pub task: Box, + #[serde(rename = "current_user_turn", skip_serializing_if = "Option::is_none")] + pub current_user_turn: Option>, + #[serde( + rename = "current_assistant_turn", + skip_serializing_if = "Option::is_none" + )] + pub current_assistant_turn: Option>, + #[serde( + rename = "current_diff_task_turn", + skip_serializing_if = "Option::is_none" + )] + pub current_diff_task_turn: Option>, +} + +impl CodeTaskDetailsResponse { + pub fn new(task: models::TaskResponse) -> CodeTaskDetailsResponse { + CodeTaskDetailsResponse { + task: Box::new(task), + current_user_turn: None, + current_assistant_turn: None, + current_diff_task_turn: None, + } + } +} diff --git a/codex-rs/codex-backend-openapi-models/src/models/external_pull_request_response.rs b/codex-rs/codex-backend-openapi-models/src/models/external_pull_request_response.rs new file mode 100644 index 00000000..92b56db2 --- /dev/null +++ b/codex-rs/codex-backend-openapi-models/src/models/external_pull_request_response.rs @@ -0,0 +1,40 @@ +/* + * codex-backend + * + * codex-backend + * + * The version of the OpenAPI document: 0.0.1 + * + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct ExternalPullRequestResponse { + #[serde(rename = "id")] + pub id: String, + #[serde(rename = "assistant_turn_id")] + pub assistant_turn_id: String, + #[serde(rename = "pull_request")] + pub pull_request: Box, + #[serde(rename = "codex_updated_sha", skip_serializing_if = "Option::is_none")] + pub codex_updated_sha: Option, +} + +impl ExternalPullRequestResponse { + pub fn new( + id: String, + assistant_turn_id: String, + pull_request: models::GitPullRequest, + ) -> ExternalPullRequestResponse { + ExternalPullRequestResponse { + id, + assistant_turn_id, + pull_request: Box::new(pull_request), + codex_updated_sha: None, + } + } +} diff --git a/codex-rs/codex-backend-openapi-models/src/models/git_pull_request.rs b/codex-rs/codex-backend-openapi-models/src/models/git_pull_request.rs new file mode 100644 index 00000000..a7e995f3 --- /dev/null +++ b/codex-rs/codex-backend-openapi-models/src/models/git_pull_request.rs @@ -0,0 +1,77 @@ +/* + * codex-backend + * + * codex-backend + * + * The version of the OpenAPI document: 0.0.1 + * + * Generated by: https://openapi-generator.tech + */ + +use serde::Deserialize; +use serde::Serialize; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct GitPullRequest { + #[serde(rename = "number")] + pub number: i32, + #[serde(rename = "url")] + pub url: String, + #[serde(rename = "state")] + pub state: String, + #[serde(rename = "merged")] + pub merged: bool, + #[serde(rename = "mergeable")] + pub mergeable: bool, + #[serde(rename = "draft", skip_serializing_if = "Option::is_none")] + pub draft: Option, + #[serde(rename = "title", skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(rename = "body", skip_serializing_if = "Option::is_none")] + pub body: Option, + #[serde(rename = "base", skip_serializing_if = "Option::is_none")] + pub base: Option, + #[serde(rename = "head", skip_serializing_if = "Option::is_none")] + pub head: Option, + #[serde(rename = "base_sha", skip_serializing_if = "Option::is_none")] + pub base_sha: Option, + #[serde(rename = "head_sha", skip_serializing_if = "Option::is_none")] + pub head_sha: Option, + #[serde(rename = "merge_commit_sha", skip_serializing_if = "Option::is_none")] + pub merge_commit_sha: Option, + #[serde(rename = "comments", skip_serializing_if = "Option::is_none")] + pub comments: Option, + #[serde(rename = "diff", skip_serializing_if = "Option::is_none")] + pub diff: Option, + #[serde(rename = "user", skip_serializing_if = "Option::is_none")] + pub user: Option, +} + +impl GitPullRequest { + pub fn new( + number: i32, + url: String, + state: String, + merged: bool, + mergeable: bool, + ) -> GitPullRequest { + GitPullRequest { + number, + url, + state, + merged, + mergeable, + draft: None, + title: None, + body: None, + base: None, + head: None, + base_sha: None, + head_sha: None, + merge_commit_sha: None, + comments: None, + diff: None, + user: None, + } + } +} diff --git a/codex-rs/codex-backend-openapi-models/src/models/mod.rs b/codex-rs/codex-backend-openapi-models/src/models/mod.rs new file mode 100644 index 00000000..e2cb972f --- /dev/null +++ b/codex-rs/codex-backend-openapi-models/src/models/mod.rs @@ -0,0 +1,22 @@ +// Curated minimal export list for current workspace usage. +// NOTE: This file was previously auto-generated by the OpenAPI generator. +// Currently export only the types referenced by the workspace +// The process for this will change + +pub mod code_task_details_response; +pub use self::code_task_details_response::CodeTaskDetailsResponse; + +pub mod task_response; +pub use self::task_response::TaskResponse; + +pub mod external_pull_request_response; +pub use self::external_pull_request_response::ExternalPullRequestResponse; + +pub mod git_pull_request; +pub use self::git_pull_request::GitPullRequest; + +pub mod task_list_item; +pub use self::task_list_item::TaskListItem; + +pub mod paginated_list_task_list_item_; +pub use self::paginated_list_task_list_item_::PaginatedListTaskListItem; diff --git a/codex-rs/codex-backend-openapi-models/src/models/paginated_list_task_list_item_.rs b/codex-rs/codex-backend-openapi-models/src/models/paginated_list_task_list_item_.rs new file mode 100644 index 00000000..5af75afa --- /dev/null +++ b/codex-rs/codex-backend-openapi-models/src/models/paginated_list_task_list_item_.rs @@ -0,0 +1,30 @@ +/* + * codex-backend + * + * codex-backend + * + * The version of the OpenAPI document: 0.0.1 + * + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct PaginatedListTaskListItem { + #[serde(rename = "items")] + pub items: Vec, + #[serde(rename = "cursor", skip_serializing_if = "Option::is_none")] + pub cursor: Option, +} + +impl PaginatedListTaskListItem { + pub fn new(items: Vec) -> PaginatedListTaskListItem { + PaginatedListTaskListItem { + items, + cursor: None, + } + } +} diff --git a/codex-rs/codex-backend-openapi-models/src/models/task_list_item.rs b/codex-rs/codex-backend-openapi-models/src/models/task_list_item.rs new file mode 100644 index 00000000..5f34738a --- /dev/null +++ b/codex-rs/codex-backend-openapi-models/src/models/task_list_item.rs @@ -0,0 +1,63 @@ +/* + * codex-backend + * + * codex-backend + * + * The version of the OpenAPI document: 0.0.1 + * + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct TaskListItem { + #[serde(rename = "id")] + pub id: String, + #[serde(rename = "title")] + pub title: String, + #[serde( + rename = "has_generated_title", + skip_serializing_if = "Option::is_none" + )] + pub has_generated_title: Option, + #[serde(rename = "updated_at", skip_serializing_if = "Option::is_none")] + pub updated_at: Option, + #[serde(rename = "created_at", skip_serializing_if = "Option::is_none")] + pub created_at: Option, + #[serde( + rename = "task_status_display", + skip_serializing_if = "Option::is_none" + )] + pub task_status_display: Option>, + #[serde(rename = "archived")] + pub archived: bool, + #[serde(rename = "has_unread_turn")] + pub has_unread_turn: bool, + #[serde(rename = "pull_requests", skip_serializing_if = "Option::is_none")] + pub pull_requests: Option>, +} + +impl TaskListItem { + pub fn new( + id: String, + title: String, + has_generated_title: Option, + archived: bool, + has_unread_turn: bool, + ) -> TaskListItem { + TaskListItem { + id, + title, + has_generated_title, + updated_at: None, + created_at: None, + task_status_display: None, + archived, + has_unread_turn, + pull_requests: None, + } + } +} diff --git a/codex-rs/codex-backend-openapi-models/src/models/task_response.rs b/codex-rs/codex-backend-openapi-models/src/models/task_response.rs new file mode 100644 index 00000000..6251b56b --- /dev/null +++ b/codex-rs/codex-backend-openapi-models/src/models/task_response.rs @@ -0,0 +1,62 @@ +/* + * codex-backend + * + * codex-backend + * + * The version of the OpenAPI document: 0.0.1 + * + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct TaskResponse { + #[serde(rename = "id")] + pub id: String, + #[serde(rename = "created_at", skip_serializing_if = "Option::is_none")] + pub created_at: Option, + #[serde(rename = "title")] + pub title: String, + #[serde( + rename = "has_generated_title", + skip_serializing_if = "Option::is_none" + )] + pub has_generated_title: Option, + #[serde(rename = "current_turn_id", skip_serializing_if = "Option::is_none")] + pub current_turn_id: Option, + #[serde(rename = "has_unread_turn", skip_serializing_if = "Option::is_none")] + pub has_unread_turn: Option, + #[serde( + rename = "denormalized_metadata", + skip_serializing_if = "Option::is_none" + )] + pub denormalized_metadata: Option>, + #[serde(rename = "archived")] + pub archived: bool, + #[serde(rename = "external_pull_requests")] + pub external_pull_requests: Vec, +} + +impl TaskResponse { + pub fn new( + id: String, + title: String, + archived: bool, + external_pull_requests: Vec, + ) -> TaskResponse { + TaskResponse { + id, + created_at: None, + title, + has_generated_title: None, + current_turn_id: None, + has_unread_turn: None, + denormalized_metadata: None, + archived, + external_pull_requests, + } + } +} diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 7d616204..865bc925 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -433,7 +433,7 @@ fn set_project_trusted_inner(doc: &mut DocumentMut, project_path: &Path) -> anyh .get_mut(project_key.as_str()) .and_then(|i| i.as_table_mut()) else { - return Err(anyhow::anyhow!("project table missing for {}", project_key)); + return Err(anyhow::anyhow!("project table missing for {project_key}")); }; proj_tbl.set_implicit(false); proj_tbl["trust_level"] = toml_edit::value("trusted"); diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index a488791c..9be7d956 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -196,8 +196,7 @@ impl McpConnectionManager { // Validate server name before spawning if !is_valid_mcp_server_name(&server_name) { let error = anyhow::anyhow!( - "invalid server name '{}': must match pattern ^[a-zA-Z0-9_-]+$", - server_name + "invalid server name '{server_name}': must match pattern ^[a-zA-Z0-9_-]+$" ); errors.insert(server_name, error); continue; diff --git a/codex-rs/git-apply/Cargo.toml b/codex-rs/git-apply/Cargo.toml new file mode 100644 index 00000000..0c17ccc3 --- /dev/null +++ b/codex-rs/git-apply/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "codex-git-apply" +version = { workspace = true } +edition = "2024" + +[lib] +name = "codex_git_apply" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +once_cell = "1" +regex = "1" +tempfile = "3" + diff --git a/codex-rs/git-apply/src/lib.rs b/codex-rs/git-apply/src/lib.rs new file mode 100644 index 00000000..5d146312 --- /dev/null +++ b/codex-rs/git-apply/src/lib.rs @@ -0,0 +1,698 @@ +use once_cell::sync::Lazy; +use regex::Regex; +use std::ffi::OsStr; +use std::io; +use std::path::Path; +use std::path::PathBuf; + +#[derive(Debug, Clone)] +pub struct ApplyGitRequest { + pub cwd: PathBuf, + pub diff: String, + pub revert: bool, + pub preflight: bool, +} + +#[derive(Debug, Clone)] +pub struct ApplyGitResult { + pub exit_code: i32, + pub applied_paths: Vec, + pub skipped_paths: Vec, + pub conflicted_paths: Vec, + pub stdout: String, + pub stderr: String, + pub cmd_for_log: String, +} + +pub fn apply_git_patch(req: &ApplyGitRequest) -> io::Result { + let git_root = resolve_git_root(&req.cwd)?; + + // Write unified diff into a temporary file + let (tmpdir, patch_path) = write_temp_patch(&req.diff)?; + // Keep tmpdir alive until function end to ensure the file exists + let _guard = tmpdir; + + if req.revert && !req.preflight { + // Stage WT paths first to avoid index mismatch on revert. + stage_paths(&git_root, &req.diff)?; + } + + // Build git args + let mut args: Vec = vec!["apply".into(), "--3way".into()]; + if req.revert { + args.push("-R".into()); + } + + // Optional: additional git config via env knob (defaults OFF) + let mut cfg_parts: Vec = Vec::new(); + if let Ok(cfg) = std::env::var("CODEX_APPLY_GIT_CFG") { + for pair in cfg.split(',') { + let p = pair.trim(); + if p.is_empty() || !p.contains('=') { + continue; + } + cfg_parts.push("-c".into()); + cfg_parts.push(p.to_string()); + } + } + + args.push(patch_path.to_string_lossy().to_string()); + + // Optional preflight: dry-run only; do not modify working tree + if req.preflight { + let mut check_args = vec!["apply".to_string(), "--check".to_string()]; + if req.revert { + check_args.push("-R".to_string()); + } + check_args.push(patch_path.to_string_lossy().to_string()); + let rendered = render_command_for_log(&git_root, &cfg_parts, &check_args); + let (c_code, c_out, c_err) = run_git(&git_root, &cfg_parts, &check_args)?; + let (mut applied_paths, mut skipped_paths, mut conflicted_paths) = + parse_git_apply_output(&c_out, &c_err); + applied_paths.sort(); + applied_paths.dedup(); + skipped_paths.sort(); + skipped_paths.dedup(); + conflicted_paths.sort(); + conflicted_paths.dedup(); + return Ok(ApplyGitResult { + exit_code: c_code, + applied_paths, + skipped_paths, + conflicted_paths, + stdout: c_out, + stderr: c_err, + cmd_for_log: rendered, + }); + } + + let cmd_for_log = render_command_for_log(&git_root, &cfg_parts, &args); + let (code, stdout, stderr) = run_git(&git_root, &cfg_parts, &args)?; + + let (mut applied_paths, mut skipped_paths, mut conflicted_paths) = + parse_git_apply_output(&stdout, &stderr); + applied_paths.sort(); + applied_paths.dedup(); + skipped_paths.sort(); + skipped_paths.dedup(); + conflicted_paths.sort(); + conflicted_paths.dedup(); + + Ok(ApplyGitResult { + exit_code: code, + applied_paths, + skipped_paths, + conflicted_paths, + stdout, + stderr, + cmd_for_log, + }) +} + +fn resolve_git_root(cwd: &Path) -> io::Result { + let out = std::process::Command::new("git") + .arg("rev-parse") + .arg("--show-toplevel") + .current_dir(cwd) + .output()?; + let code = out.status.code().unwrap_or(-1); + if code != 0 { + return Err(io::Error::other(format!( + "not a git repository (exit {}): {}", + code, + String::from_utf8_lossy(&out.stderr) + ))); + } + let root = String::from_utf8_lossy(&out.stdout).trim().to_string(); + Ok(PathBuf::from(root)) +} + +fn write_temp_patch(diff: &str) -> io::Result<(tempfile::TempDir, PathBuf)> { + let dir = tempfile::tempdir()?; + let path = dir.path().join("patch.diff"); + std::fs::write(&path, diff)?; + Ok((dir, path)) +} + +fn run_git(cwd: &Path, git_cfg: &[String], args: &[String]) -> io::Result<(i32, String, String)> { + let mut cmd = std::process::Command::new("git"); + for p in git_cfg { + cmd.arg(p); + } + for a in args { + cmd.arg(a); + } + let out = cmd.current_dir(cwd).output()?; + let code = out.status.code().unwrap_or(-1); + let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); + Ok((code, stdout, stderr)) +} + +fn quote_shell(s: &str) -> String { + let simple = s + .chars() + .all(|c| c.is_ascii_alphanumeric() || "-_.:/@%+".contains(c)); + if simple { + s.to_string() + } else { + format!("'{}'", s.replace('\'', "'\\''")) + } +} + +fn render_command_for_log(cwd: &Path, git_cfg: &[String], args: &[String]) -> String { + let mut parts: Vec = Vec::new(); + parts.push("git".to_string()); + for a in git_cfg { + parts.push(quote_shell(a)); + } + for a in args { + parts.push(quote_shell(a)); + } + format!( + "(cd {} && {})", + quote_shell(&cwd.display().to_string()), + parts.join(" ") + ) +} + +pub fn extract_paths_from_patch(diff_text: &str) -> Vec { + static RE: Lazy = Lazy::new(|| { + Regex::new(r"(?m)^diff --git a/(.*?) b/(.*)$") + .unwrap_or_else(|e| panic!("invalid regex: {e}")) + }); + let mut set = std::collections::BTreeSet::new(); + for caps in RE.captures_iter(diff_text) { + if let Some(a) = caps.get(1).map(|m| m.as_str()) + && a != "/dev/null" + && !a.trim().is_empty() + { + set.insert(a.to_string()); + } + if let Some(b) = caps.get(2).map(|m| m.as_str()) + && b != "/dev/null" + && !b.trim().is_empty() + { + set.insert(b.to_string()); + } + } + set.into_iter().collect() +} + +pub fn stage_paths(git_root: &Path, diff: &str) -> io::Result<()> { + let paths = extract_paths_from_patch(diff); + let mut existing: Vec = Vec::new(); + for p in paths { + let joined = git_root.join(&p); + if std::fs::symlink_metadata(&joined).is_ok() { + existing.push(p); + } + } + if existing.is_empty() { + return Ok(()); + } + let mut cmd = std::process::Command::new("git"); + cmd.arg("add"); + cmd.arg("--"); + for p in &existing { + cmd.arg(OsStr::new(p)); + } + let out = cmd.current_dir(git_root).output()?; + let _code = out.status.code().unwrap_or(-1); + // We do not hard fail staging; best-effort is OK. Return Ok even on non-zero. + Ok(()) +} + +// ============ Parser ported from VS Code (TS) ============ + +pub fn parse_git_apply_output( + stdout: &str, + stderr: &str, +) -> (Vec, Vec, Vec) { + let combined = [stdout, stderr] + .iter() + .filter(|s| !s.is_empty()) + .cloned() + .collect::>() + .join("\n"); + + let mut applied = std::collections::BTreeSet::new(); + let mut skipped = std::collections::BTreeSet::new(); + let mut conflicted = std::collections::BTreeSet::new(); + let mut last_seen_path: Option = None; + + fn add(set: &mut std::collections::BTreeSet, raw: &str) { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return; + } + let first = trimmed.chars().next().unwrap_or('\0'); + let last = trimmed.chars().last().unwrap_or('\0'); + let unquoted = if (first == '"' || first == '\'') && last == first && trimmed.len() >= 2 { + &trimmed[1..trimmed.len() - 1] + } else { + trimmed + }; + if !unquoted.is_empty() { + set.insert(unquoted.to_string()); + } + } + + static APPLIED_CLEAN: Lazy = + Lazy::new(|| regex_ci("^Applied patch(?: to)?\\s+(?P.+?)\\s+cleanly\\.?$")); + static APPLIED_CONFLICTS: Lazy = + Lazy::new(|| regex_ci("^Applied patch(?: to)?\\s+(?P.+?)\\s+with conflicts\\.?$")); + static APPLYING_WITH_REJECTS: Lazy = Lazy::new(|| { + regex_ci("^Applying patch\\s+(?P.+?)\\s+with\\s+\\d+\\s+rejects?\\.{0,3}$") + }); + static CHECKING_PATCH: Lazy = + Lazy::new(|| regex_ci("^Checking patch\\s+(?P.+?)\\.\\.\\.$")); + static UNMERGED_LINE: Lazy = Lazy::new(|| regex_ci("^U\\s+(?P.+)$")); + static PATCH_FAILED: Lazy = + Lazy::new(|| regex_ci("^error:\\s+patch failed:\\s+(?P.+?)(?::\\d+)?(?:\\s|$)")); + static DOES_NOT_APPLY: Lazy = + Lazy::new(|| regex_ci("^error:\\s+(?P.+?):\\s+patch does not apply$")); + static THREE_WAY_START: Lazy = Lazy::new(|| { + regex_ci("^(?:Performing three-way merge|Falling back to three-way merge)\\.\\.\\.$") + }); + static THREE_WAY_FAILED: Lazy = + Lazy::new(|| regex_ci("^Failed to perform three-way merge\\.\\.\\.$")); + static FALLBACK_DIRECT: Lazy = + Lazy::new(|| regex_ci("^Falling back to direct application\\.\\.\\.$")); + static LACKS_BLOB: Lazy = Lazy::new(|| { + regex_ci( + "^(?:error: )?repository lacks the necessary blob to (?:perform|fall back on) 3-?way merge\\.?$", + ) + }); + static INDEX_MISMATCH: Lazy = + Lazy::new(|| regex_ci("^error:\\s+(?P.+?):\\s+does not match index\\b")); + static NOT_IN_INDEX: Lazy = + Lazy::new(|| regex_ci("^error:\\s+(?P.+?):\\s+does not exist in index\\b")); + static ALREADY_EXISTS_WT: Lazy = Lazy::new(|| { + regex_ci("^error:\\s+(?P.+?)\\s+already exists in (?:the )?working directory\\b") + }); + static FILE_EXISTS: Lazy = + Lazy::new(|| regex_ci("^error:\\s+patch failed:\\s+(?P.+?)\\s+File exists")); + static RENAMED_DELETED: Lazy = + Lazy::new(|| regex_ci("^error:\\s+path\\s+(?P.+?)\\s+has been renamed\\/deleted")); + static CANNOT_APPLY_BINARY: Lazy = Lazy::new(|| { + regex_ci( + "^error:\\s+cannot apply binary patch to\\s+['\\\"]?(?P.+?)['\\\"]?\\s+without full index line$", + ) + }); + static BINARY_DOES_NOT_APPLY: Lazy = Lazy::new(|| { + regex_ci("^error:\\s+binary patch does not apply to\\s+['\\\"]?(?P.+?)['\\\"]?$") + }); + static BINARY_INCORRECT_RESULT: Lazy = Lazy::new(|| { + regex_ci( + "^error:\\s+binary patch to\\s+['\\\"]?(?P.+?)['\\\"]?\\s+creates incorrect result\\b", + ) + }); + static CANNOT_READ_CURRENT: Lazy = Lazy::new(|| { + regex_ci("^error:\\s+cannot read the current contents of\\s+['\\\"]?(?P.+?)['\\\"]?$") + }); + static SKIPPED_PATCH: Lazy = + Lazy::new(|| regex_ci("^Skipped patch\\s+['\\\"]?(?P.+?)['\\\"]\\.$")); + static CANNOT_MERGE_BINARY_WARN: Lazy = Lazy::new(|| { + regex_ci( + "^warning:\\s*Cannot merge binary files:\\s+(?P.+?)\\s+\\(ours\\s+vs\\.\\s+theirs\\)", + ) + }); + + for raw_line in combined.lines() { + let line = raw_line.trim(); + if line.is_empty() { + continue; + } + + // === "Checking patch ..." tracking === + if let Some(c) = CHECKING_PATCH.captures(line) { + if let Some(m) = c.name("path") { + last_seen_path = Some(m.as_str().to_string()); + } + continue; + } + + // === Status lines === + if let Some(c) = APPLIED_CLEAN.captures(line) { + if let Some(m) = c.name("path") { + add(&mut applied, m.as_str()); + let p = applied.iter().next_back().cloned(); + if let Some(p) = p { + conflicted.remove(&p); + skipped.remove(&p); + last_seen_path = Some(p); + } + } + continue; + } + if let Some(c) = APPLIED_CONFLICTS.captures(line) { + if let Some(m) = c.name("path") { + add(&mut conflicted, m.as_str()); + let p = conflicted.iter().next_back().cloned(); + if let Some(p) = p { + applied.remove(&p); + skipped.remove(&p); + last_seen_path = Some(p); + } + } + continue; + } + if let Some(c) = APPLYING_WITH_REJECTS.captures(line) { + if let Some(m) = c.name("path") { + add(&mut conflicted, m.as_str()); + let p = conflicted.iter().next_back().cloned(); + if let Some(p) = p { + applied.remove(&p); + skipped.remove(&p); + last_seen_path = Some(p); + } + } + continue; + } + + // === “U ” after conflicts === + if let Some(c) = UNMERGED_LINE.captures(line) { + if let Some(m) = c.name("path") { + add(&mut conflicted, m.as_str()); + let p = conflicted.iter().next_back().cloned(); + if let Some(p) = p { + applied.remove(&p); + skipped.remove(&p); + last_seen_path = Some(p); + } + } + continue; + } + + // === Early hints === + if PATCH_FAILED.is_match(line) || DOES_NOT_APPLY.is_match(line) { + if let Some(c) = PATCH_FAILED + .captures(line) + .or_else(|| DOES_NOT_APPLY.captures(line)) + && let Some(m) = c.name("path") + { + add(&mut skipped, m.as_str()); + last_seen_path = Some(m.as_str().to_string()); + } + continue; + } + + // === Ignore narration === + if THREE_WAY_START.is_match(line) || FALLBACK_DIRECT.is_match(line) { + continue; + } + + // === 3-way failed entirely; attribute to last_seen_path === + if THREE_WAY_FAILED.is_match(line) || LACKS_BLOB.is_match(line) { + if let Some(p) = last_seen_path.clone() { + add(&mut skipped, &p); + applied.remove(&p); + conflicted.remove(&p); + } + continue; + } + + // === Skips / I/O problems === + if let Some(c) = INDEX_MISMATCH + .captures(line) + .or_else(|| NOT_IN_INDEX.captures(line)) + .or_else(|| ALREADY_EXISTS_WT.captures(line)) + .or_else(|| FILE_EXISTS.captures(line)) + .or_else(|| RENAMED_DELETED.captures(line)) + .or_else(|| CANNOT_APPLY_BINARY.captures(line)) + .or_else(|| BINARY_DOES_NOT_APPLY.captures(line)) + .or_else(|| BINARY_INCORRECT_RESULT.captures(line)) + .or_else(|| CANNOT_READ_CURRENT.captures(line)) + .or_else(|| SKIPPED_PATCH.captures(line)) + { + if let Some(m) = c.name("path") { + add(&mut skipped, m.as_str()); + let p_now = skipped.iter().next_back().cloned(); + if let Some(p) = p_now { + applied.remove(&p); + conflicted.remove(&p); + last_seen_path = Some(p); + } + } + continue; + } + + // === Warnings that imply conflicts === + if let Some(c) = CANNOT_MERGE_BINARY_WARN.captures(line) { + if let Some(m) = c.name("path") { + add(&mut conflicted, m.as_str()); + let p = conflicted.iter().next_back().cloned(); + if let Some(p) = p { + applied.remove(&p); + skipped.remove(&p); + last_seen_path = Some(p); + } + } + continue; + } + } + + // Final precedence: conflicts > applied > skipped + for p in conflicted.iter() { + applied.remove(p); + skipped.remove(p); + } + for p in applied.iter() { + skipped.remove(p); + } + + ( + applied.into_iter().collect(), + skipped.into_iter().collect(), + conflicted.into_iter().collect(), + ) +} + +fn regex_ci(pat: &str) -> Regex { + Regex::new(&format!("(?i){pat}")).unwrap_or_else(|e| panic!("invalid regex: {e}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + use std::sync::Mutex; + use std::sync::OnceLock; + + fn env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + + fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) { + let out = std::process::Command::new(args[0]) + .args(&args[1..]) + .current_dir(cwd) + .output() + .expect("spawn ok"); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) + } + + fn init_repo() -> tempfile::TempDir { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + // git init and minimal identity + let _ = run(root, &["git", "init"]); + let _ = run(root, &["git", "config", "user.email", "codex@example.com"]); + let _ = run(root, &["git", "config", "user.name", "Codex"]); + dir + } + + fn read_file_normalized(path: &Path) -> String { + std::fs::read_to_string(path) + .expect("read file") + .replace("\r\n", "\n") + } + + #[test] + fn apply_add_success() { + let _g = env_lock().lock().unwrap(); + let repo = init_repo(); + let root = repo.path(); + + let diff = "diff --git a/hello.txt b/hello.txt\nnew file mode 100644\n--- /dev/null\n+++ b/hello.txt\n@@ -0,0 +1,2 @@\n+hello\n+world\n"; + let req = ApplyGitRequest { + cwd: root.to_path_buf(), + diff: diff.to_string(), + revert: false, + preflight: false, + }; + let r = apply_git_patch(&req).expect("run apply"); + assert_eq!(r.exit_code, 0, "exit code 0"); + // File exists now + assert!(root.join("hello.txt").exists()); + } + + #[test] + fn apply_modify_conflict() { + let _g = env_lock().lock().unwrap(); + let repo = init_repo(); + let root = repo.path(); + // seed file and commit + std::fs::write(root.join("file.txt"), "line1\nline2\nline3\n").unwrap(); + let _ = run(root, &["git", "add", "file.txt"]); + let _ = run(root, &["git", "commit", "-m", "seed"]); + // local edit (unstaged) + std::fs::write(root.join("file.txt"), "line1\nlocal2\nline3\n").unwrap(); + // patch wants to change the same line differently + let diff = "diff --git a/file.txt b/file.txt\n--- a/file.txt\n+++ b/file.txt\n@@ -1,3 +1,3 @@\n line1\n-line2\n+remote2\n line3\n"; + let req = ApplyGitRequest { + cwd: root.to_path_buf(), + diff: diff.to_string(), + revert: false, + preflight: false, + }; + let r = apply_git_patch(&req).expect("run apply"); + assert_ne!(r.exit_code, 0, "non-zero exit on conflict"); + } + + #[test] + fn apply_modify_skipped_missing_index() { + let _g = env_lock().lock().unwrap(); + let repo = init_repo(); + let root = repo.path(); + // Try to modify a file that is not in the index + let diff = "diff --git a/ghost.txt b/ghost.txt\n--- a/ghost.txt\n+++ b/ghost.txt\n@@ -1,1 +1,1 @@\n-old\n+new\n"; + let req = ApplyGitRequest { + cwd: root.to_path_buf(), + diff: diff.to_string(), + revert: false, + preflight: false, + }; + let r = apply_git_patch(&req).expect("run apply"); + assert_ne!(r.exit_code, 0, "non-zero exit on missing index"); + } + + #[test] + fn apply_then_revert_success() { + let _g = env_lock().lock().unwrap(); + let repo = init_repo(); + let root = repo.path(); + // Seed file and commit original content + std::fs::write(root.join("file.txt"), "orig\n").unwrap(); + let _ = run(root, &["git", "add", "file.txt"]); + let _ = run(root, &["git", "commit", "-m", "seed"]); + + // Forward patch: orig -> ORIG + let diff = "diff --git a/file.txt b/file.txt\n--- a/file.txt\n+++ b/file.txt\n@@ -1,1 +1,1 @@\n-orig\n+ORIG\n"; + let apply_req = ApplyGitRequest { + cwd: root.to_path_buf(), + diff: diff.to_string(), + revert: false, + preflight: false, + }; + let res_apply = apply_git_patch(&apply_req).expect("apply ok"); + assert_eq!(res_apply.exit_code, 0, "forward apply succeeded"); + let after_apply = read_file_normalized(&root.join("file.txt")); + assert_eq!(after_apply, "ORIG\n"); + + // Revert patch: ORIG -> orig (stage paths first; engine handles it) + let revert_req = ApplyGitRequest { + cwd: root.to_path_buf(), + diff: diff.to_string(), + revert: true, + preflight: false, + }; + let res_revert = apply_git_patch(&revert_req).expect("revert ok"); + assert_eq!(res_revert.exit_code, 0, "revert apply succeeded"); + let after_revert = read_file_normalized(&root.join("file.txt")); + assert_eq!(after_revert, "orig\n"); + } + + #[test] + fn revert_preflight_does_not_stage_index() { + let _g = env_lock().lock().unwrap(); + let repo = init_repo(); + let root = repo.path(); + // Seed repo and apply forward patch so the working tree reflects the change. + std::fs::write(root.join("file.txt"), "orig\n").unwrap(); + let _ = run(root, &["git", "add", "file.txt"]); + let _ = run(root, &["git", "commit", "-m", "seed"]); + + let diff = "diff --git a/file.txt b/file.txt\n--- a/file.txt\n+++ b/file.txt\n@@ -1,1 +1,1 @@\n-orig\n+ORIG\n"; + let apply_req = ApplyGitRequest { + cwd: root.to_path_buf(), + diff: diff.to_string(), + revert: false, + preflight: false, + }; + let res_apply = apply_git_patch(&apply_req).expect("apply ok"); + assert_eq!(res_apply.exit_code, 0, "forward apply succeeded"); + let (commit_code, _, commit_err) = run(root, &["git", "commit", "-am", "apply change"]); + assert_eq!(commit_code, 0, "commit applied change: {commit_err}"); + + let (_code_before, staged_before, _stderr_before) = + run(root, &["git", "diff", "--cached", "--name-only"]); + + let preflight_req = ApplyGitRequest { + cwd: root.to_path_buf(), + diff: diff.to_string(), + revert: true, + preflight: true, + }; + let res_preflight = apply_git_patch(&preflight_req).expect("preflight ok"); + assert_eq!(res_preflight.exit_code, 0, "revert preflight succeeded"); + let (_code_after, staged_after, _stderr_after) = + run(root, &["git", "diff", "--cached", "--name-only"]); + assert_eq!( + staged_after.trim(), + staged_before.trim(), + "preflight should not stage new paths", + ); + + let after_preflight = read_file_normalized(&root.join("file.txt")); + assert_eq!(after_preflight, "ORIG\n"); + } + + #[test] + fn preflight_blocks_partial_changes() { + let _g = env_lock().lock().unwrap(); + let repo = init_repo(); + let root = repo.path(); + // Build a multi-file diff: one valid add (ok.txt) and one invalid modify (ghost.txt) + let diff = "diff --git a/ok.txt b/ok.txt\nnew file mode 100644\n--- /dev/null\n+++ b/ok.txt\n@@ -0,0 +1,2 @@\n+alpha\n+beta\n\n\ +diff --git a/ghost.txt b/ghost.txt\n--- a/ghost.txt\n+++ b/ghost.txt\n@@ -1,1 +1,1 @@\n-old\n+new\n"; + + // 1) With preflight enabled, nothing should be changed (even though ok.txt could be added) + let req1 = ApplyGitRequest { + cwd: root.to_path_buf(), + diff: diff.to_string(), + revert: false, + preflight: true, + }; + let r1 = apply_git_patch(&req1).expect("preflight apply"); + assert_ne!(r1.exit_code, 0, "preflight reports failure"); + assert!( + !root.join("ok.txt").exists(), + "preflight must prevent adding ok.txt" + ); + assert!( + r1.cmd_for_log.contains("--check"), + "preflight path recorded --check" + ); + + // 2) Without preflight, we should see no --check in the executed command + let req2 = ApplyGitRequest { + cwd: root.to_path_buf(), + diff: diff.to_string(), + revert: false, + preflight: false, + }; + let r2 = apply_git_patch(&req2).expect("direct apply"); + assert_ne!(r2.exit_code, 0, "apply is expected to fail overall"); + assert!( + !r2.cmd_for_log.contains("--check"), + "non-preflight path should not use --check" + ); + } +} diff --git a/codex-rs/mcp-client/src/mcp_client.rs b/codex-rs/mcp-client/src/mcp_client.rs index 087335e6..27f96494 100644 --- a/codex-rs/mcp-client/src/mcp_client.rs +++ b/codex-rs/mcp-client/src/mcp_client.rs @@ -274,8 +274,7 @@ impl McpClient { err.error.code, err.error.message ))), other => Err(anyhow!(format!( - "unexpected message variant received in reply path: {:?}", - other + "unexpected message variant received in reply path: {other:?}" ))), } } diff --git a/codex-rs/protocol-ts/src/lib.rs b/codex-rs/protocol-ts/src/lib.rs index 12c0b7a1..848aeb25 100644 --- a/codex-rs/protocol-ts/src/lib.rs +++ b/codex-rs/protocol-ts/src/lib.rs @@ -71,7 +71,7 @@ pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> { .status() .with_context(|| format!("Failed to invoke Prettier at {}", prettier_bin.display()))?; if !status.success() { - return Err(anyhow!("Prettier failed with status {}", status)); + return Err(anyhow!("Prettier failed with status {status}")); } } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index fd301913..ec231437 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -7,6 +7,7 @@ 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; @@ -93,6 +94,7 @@ pub(crate) struct ChatComposer { disable_paste_burst: bool, custom_prompts: Vec, footer_mode: FooterMode, + footer_hint_override: Option>, } /// Popup state – at most one can be visible at any time. @@ -134,6 +136,7 @@ impl ChatComposer { disable_paste_burst: false, custom_prompts: Vec::new(), footer_mode: FooterMode::ShortcutPrompt, + footer_hint_override: None, }; // Apply configuration via the setter to keep side-effects centralized. this.set_disable_paste_burst(disable_paste_burst); @@ -142,7 +145,9 @@ impl ChatComposer { pub fn desired_height(&self, width: u16) -> u16 { let footer_props = self.footer_props(); - let footer_hint_height = footer_height(footer_props); + let footer_hint_height = self + .custom_footer_height() + .unwrap_or_else(|| footer_height(footer_props)); let footer_spacing = Self::footer_spacing(footer_hint_height); let footer_total_height = footer_hint_height + footer_spacing; self.textarea @@ -157,7 +162,9 @@ impl ChatComposer { fn layout_areas(&self, area: Rect) -> [Rect; 3] { let footer_props = self.footer_props(); - let footer_hint_height = footer_height(footer_props); + let footer_hint_height = self + .custom_footer_height() + .unwrap_or_else(|| footer_height(footer_props)); let footer_spacing = Self::footer_spacing(footer_hint_height); let footer_total_height = footer_hint_height + footer_spacing; let popup_constraint = match &self.active_popup { @@ -273,6 +280,12 @@ impl ChatComposer { } } + /// Override the footer hint items displayed beneath the composer. Passing + /// `None` restores the default shortcut footer. + pub(crate) fn set_footer_hint_override(&mut self, items: Option>) { + self.footer_hint_override = items; + } + /// Replace the entire composer content with `text` and reset cursor. pub(crate) fn set_text_content(&mut self, text: String) { // Clear any existing content, placeholders, and attachments first. @@ -1304,6 +1317,12 @@ impl ChatComposer { } } + fn custom_footer_height(&self) -> Option { + self.footer_hint_override + .as_ref() + .map(|items| if items.is_empty() { 0 } else { 1 }) + } + /// Synchronize `self.command_popup` with the current text in the /// textarea. This must be called after every modification that can change /// the text so the popup is shown/updated/hidden as appropriate. @@ -1436,7 +1455,9 @@ impl WidgetRef for ChatComposer { } ActivePopup::None => { let footer_props = self.footer_props(); - let footer_hint_height = footer_height(footer_props); + let custom_height = self.custom_footer_height(); + let footer_hint_height = + custom_height.unwrap_or_else(|| footer_height(footer_props)); let footer_spacing = Self::footer_spacing(footer_hint_height); let hint_rect = if footer_spacing > 0 && footer_hint_height > 0 { let [_, hint_rect] = Layout::vertical([ @@ -1448,7 +1469,27 @@ impl WidgetRef for ChatComposer { } else { popup_rect }; - render_footer(hint_rect, buf, footer_props); + 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); + } + } else { + render_footer(hint_rect, buf, footer_props); + } } } let style = user_message_style(terminal_palette::default_bg()); diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 5e42e203..5db6ebe1 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -875,7 +875,7 @@ pub(crate) fn new_mcp_tools_output( lines.push(vec![" • Server: ".into(), server.clone().into()].into()); match &cfg.transport { - McpServerTransportConfig::Stdio { command, args, .. } => { + McpServerTransportConfig::Stdio { command, args, env } => { let args_suffix = if args.is_empty() { String::new() } else { @@ -883,6 +883,15 @@ pub(crate) fn new_mcp_tools_output( }; let cmd_display = format!("{command}{args_suffix}"); lines.push(vec![" • Command: ".into(), cmd_display.into()].into()); + + if let Some(env) = env.as_ref() + && !env.is_empty() + { + let mut env_pairs: Vec = + env.iter().map(|(k, v)| format!("{k}={v}")).collect(); + env_pairs.sort(); + lines.push(vec![" • Env: ".into(), env_pairs.join(" ").into()].into()); + } } McpServerTransportConfig::StreamableHttp { url, .. } => { lines.push(vec![" • URL: ".into(), url.clone().into()].into()); diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index dd3dcb9e..1d66251f 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -55,6 +55,7 @@ mod markdown_render; mod markdown_stream; pub mod onboarding; mod pager_overlay; +pub mod public_widgets; mod render; mod resume_picker; mod session_log; @@ -82,6 +83,9 @@ use crate::onboarding::onboarding_screen::OnboardingScreenArgs; use crate::onboarding::onboarding_screen::run_onboarding_app; use crate::tui::Tui; pub use cli::Cli; +pub use markdown_render::render_markdown_text; +pub use public_widgets::composer_input::ComposerAction; +pub use public_widgets::composer_input::ComposerInput; // (tests access modules directly within the crate) diff --git a/codex-rs/tui/src/markdown_render.rs b/codex-rs/tui/src/markdown_render.rs index cac40cff..48393463 100644 --- a/codex-rs/tui/src/markdown_render.rs +++ b/codex-rs/tui/src/markdown_render.rs @@ -33,7 +33,7 @@ impl IndentContext { } #[allow(dead_code)] -pub(crate) fn render_markdown_text(input: &str) -> Text<'static> { +pub fn render_markdown_text(input: &str) -> Text<'static> { let mut options = Options::empty(); options.insert(Options::ENABLE_STRIKETHROUGH); let parser = Parser::new_ext(input, options); diff --git a/codex-rs/tui/src/public_widgets/composer_input.rs b/codex-rs/tui/src/public_widgets/composer_input.rs new file mode 100644 index 00000000..457e37fe --- /dev/null +++ b/codex-rs/tui/src/public_widgets/composer_input.rs @@ -0,0 +1,128 @@ +//! Public wrapper around the internal ChatComposer for simple, reusable text input. +//! +//! This exposes a minimal interface suitable for other crates (e.g., +//! codex-cloud-tasks) to reuse the mature composer behavior: multi-line input, +//! paste heuristics, Enter-to-submit, and Shift+Enter for newline. + +use crossterm::event::KeyEvent; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::widgets::WidgetRef; +use std::time::Duration; + +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::ChatComposer; +use crate::bottom_pane::InputResult; + +/// Action returned from feeding a key event into the ComposerInput. +pub enum ComposerAction { + /// The user submitted the current text (typically via Enter). Contains the submitted text. + Submitted(String), + /// No submission occurred; UI may need to redraw if `needs_redraw()` returned true. + None, +} + +/// A minimal, public wrapper for the internal `ChatComposer` that behaves as a +/// reusable text input field with submit semantics. +pub struct ComposerInput { + inner: ChatComposer, + _tx: tokio::sync::mpsc::UnboundedSender, + rx: tokio::sync::mpsc::UnboundedReceiver, +} + +impl ComposerInput { + /// Create a new composer input with a neutral placeholder. + pub fn new() -> Self { + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let sender = AppEventSender::new(tx.clone()); + // `enhanced_keys_supported=true` enables Shift+Enter newline hint/behavior. + let inner = ChatComposer::new(true, sender, true, "Compose new task".to_string(), false); + Self { inner, _tx: tx, rx } + } + + /// Returns true if the input is empty. + pub fn is_empty(&self) -> bool { + self.inner.is_empty() + } + + /// Clear the input text. + pub fn clear(&mut self) { + self.inner.set_text_content(String::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), + _ => ComposerAction::None, + }; + self.drain_app_events(); + action + } + + pub fn handle_paste(&mut self, pasted: String) -> bool { + let handled = self.inner.handle_paste(pasted); + self.drain_app_events(); + handled + } + + /// Override the footer hint items displayed under the composer. + /// Each tuple is rendered as "