diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index e0fc2b28682..f0bb43940d8 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -641,7 +641,6 @@ "colorchoice_1.0.4": "{\"dependencies\":[],\"features\":{}}", "combine_4.6.7": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-std\",\"req\":\"^1\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"bytes\",\"req\":\"^1\"},{\"name\":\"bytes_05\",\"optional\":true,\"package\":\"bytes\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"bytes_05\",\"package\":\"bytes\",\"req\":\"^0.5\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"futures-03-dep\",\"package\":\"futures\",\"req\":\"^0.3.1\"},{\"default_features\":false,\"name\":\"futures-core-03\",\"optional\":true,\"package\":\"futures-core\",\"req\":\"^0.3.1\"},{\"default_features\":false,\"name\":\"futures-io-03\",\"optional\":true,\"package\":\"futures-io\",\"req\":\"^0.3.1\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2.3\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.0\"},{\"features\":[\"tokio\",\"quickcheck\"],\"kind\":\"dev\",\"name\":\"partial-io\",\"req\":\"^0.3\"},{\"name\":\"pin-project-lite\",\"optional\":true,\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"quick-error\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^0.6\"},{\"name\":\"regex\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"io-util\"],\"name\":\"tokio-02-dep\",\"optional\":true,\"package\":\"tokio\",\"req\":\"^0.2.3\"},{\"features\":[\"fs\",\"io-driver\",\"io-util\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio-02-dep\",\"package\":\"tokio\",\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"tokio-03-dep\",\"optional\":true,\"package\":\"tokio\",\"req\":\"^0.3\"},{\"features\":[\"fs\",\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio-03-dep\",\"package\":\"tokio\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"tokio-dep\",\"optional\":true,\"package\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"fs\",\"macros\",\"rt\",\"rt-multi-thread\",\"io-util\"],\"kind\":\"dev\",\"name\":\"tokio-dep\",\"package\":\"tokio\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"codec\"],\"name\":\"tokio-util\",\"optional\":true,\"req\":\"^0.7\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"futures-03\":[\"pin-project\",\"std\",\"futures-core-03\",\"futures-io-03\",\"pin-project-lite\"],\"mp4\":[],\"pin-project\":[\"pin-project-lite\"],\"std\":[\"memchr/std\",\"bytes\",\"alloc\"],\"tokio\":[\"tokio-dep\",\"tokio-util/io\",\"futures-core-03\",\"pin-project-lite\"],\"tokio-02\":[\"pin-project\",\"std\",\"tokio-02-dep\",\"futures-core-03\",\"pin-project-lite\",\"bytes_05\"],\"tokio-03\":[\"pin-project\",\"std\",\"tokio-03-dep\",\"futures-core-03\",\"pin-project-lite\"]}}", "compact_str_0.8.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"borsh\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"castaway\",\"req\":\"^0.2.3\"},{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"cfg-if\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"diesel\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"itoa\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"markup\",\"optional\":true,\"req\":\"^0.13\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"proptest\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"quickcheck_macros\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"size_32\"],\"name\":\"rkyv\",\"optional\":true,\"req\":\"^0.7\"},{\"default_features\":false,\"features\":[\"alloc\",\"size_32\"],\"kind\":\"dev\",\"name\":\"rkyv\",\"req\":\"^0.7\"},{\"name\":\"rustversion\",\"req\":\"^1\"},{\"name\":\"ryu\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"derive\",\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"features\":[\"union\"],\"name\":\"smallvec\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"name\":\"sqlx\",\"optional\":true,\"req\":\"^0.7\"},{\"name\":\"static_assertions\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"test-case\",\"req\":\"^3\"},{\"kind\":\"dev\",\"name\":\"test-strategy\",\"req\":\"^0.3\"}],\"features\":{\"arbitrary\":[\"dep:arbitrary\"],\"borsh\":[\"dep:borsh\"],\"bytes\":[\"dep:bytes\"],\"default\":[\"std\"],\"diesel\":[\"dep:diesel\"],\"markup\":[\"dep:markup\"],\"proptest\":[\"dep:proptest\"],\"quickcheck\":[\"dep:quickcheck\"],\"rkyv\":[\"dep:rkyv\"],\"serde\":[\"dep:serde\"],\"smallvec\":[\"dep:smallvec\"],\"sqlx\":[\"dep:sqlx\",\"std\"],\"sqlx-mysql\":[\"sqlx\",\"sqlx/mysql\"],\"sqlx-postgres\":[\"sqlx\",\"sqlx/postgres\"],\"sqlx-sqlite\":[\"sqlx\",\"sqlx/sqlite\"],\"std\":[]}}", - "compact_str_0.9.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"borsh\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"castaway\",\"req\":\"^0.2.3\"},{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"cfg-if\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"diesel\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"itoa\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"markup\",\"optional\":true,\"req\":\"^0.15\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"proptest\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"quickcheck_macros\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"rkyv\",\"optional\":true,\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rkyv\",\"req\":\"^0.8.8\"},{\"name\":\"rustversion\",\"req\":\"^1\"},{\"name\":\"ryu\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"derive\",\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"features\":[\"union\"],\"name\":\"smallvec\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"name\":\"sqlx\",\"optional\":true,\"req\":\"^0.8\"},{\"name\":\"static_assertions\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"test-case\",\"req\":\"^3\"},{\"kind\":\"dev\",\"name\":\"test-strategy\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"arbitrary\":[\"dep:arbitrary\"],\"borsh\":[\"dep:borsh\"],\"bytes\":[\"dep:bytes\"],\"default\":[\"std\"],\"diesel\":[\"dep:diesel\"],\"markup\":[\"dep:markup\"],\"proptest\":[\"dep:proptest\"],\"quickcheck\":[\"dep:quickcheck\"],\"rkyv\":[\"dep:rkyv\"],\"serde\":[\"dep:serde\"],\"smallvec\":[\"dep:smallvec\"],\"sqlx\":[\"dep:sqlx\",\"std\"],\"sqlx-mysql\":[\"sqlx\",\"sqlx/mysql\"],\"sqlx-postgres\":[\"sqlx\",\"sqlx/postgres\"],\"sqlx-sqlite\":[\"sqlx\",\"sqlx/sqlite\"],\"std\":[],\"zeroize\":[\"dep:zeroize\"]}}", "concurrent-queue_2.5.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"cargo_bench_support\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"crossbeam-utils\",\"req\":\"^0.8.11\"},{\"kind\":\"dev\",\"name\":\"easy-parallel\",\"req\":\"^3.1.0\"},{\"kind\":\"dev\",\"name\":\"fastrand\",\"req\":\"^2.0.0\"},{\"name\":\"loom\",\"optional\":true,\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"default_features\":false,\"name\":\"portable-atomic\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "console_0.15.11": "{\"dependencies\":[{\"name\":\"encode_unicode\",\"req\":\"^1\",\"target\":\"cfg(windows)\"},{\"name\":\"libc\",\"req\":\"^0.2.99\"},{\"name\":\"once_cell\",\"req\":\"^1.8\"},{\"default_features\":false,\"features\":[\"std\",\"bit-set\",\"break-dead-code\"],\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.4.2\"},{\"name\":\"unicode-width\",\"optional\":true,\"req\":\"^0.2\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Console\",\"Win32_Storage_FileSystem\",\"Win32_UI_Input_KeyboardAndMouse\"],\"name\":\"windows-sys\",\"req\":\"^0.59\",\"target\":\"cfg(windows)\"}],\"features\":{\"ansi-parsing\":[],\"default\":[\"unicode-width\",\"ansi-parsing\"],\"windows-console-colors\":[\"ansi-parsing\"]}}", "const-hex_1.17.0": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"cpufeatures\",\"req\":\"^0.2\",\"target\":\"cfg(any(target_arch = \\\"x86\\\", target_arch = \\\"x86_64\\\"))\"},{\"kind\":\"dev\",\"name\":\"divan\",\"package\":\"codspeed-divan-compat\",\"req\":\"^3\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"faster-hex\",\"req\":\"^0.10.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"~0.4.2\"},{\"default_features\":false,\"name\":\"proptest\",\"optional\":true,\"req\":\"^1.4\"},{\"kind\":\"dev\",\"name\":\"rustc-hex\",\"req\":\"^2.1\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"__fuzzing\":[\"dep:proptest\",\"std\"],\"alloc\":[\"serde_core?/alloc\",\"proptest?/alloc\"],\"core-error\":[],\"default\":[\"std\"],\"force-generic\":[],\"hex\":[],\"nightly\":[],\"portable-simd\":[],\"serde\":[\"dep:serde_core\"],\"std\":[\"serde_core?/std\",\"proptest?/std\",\"alloc\"]}}", @@ -697,7 +696,6 @@ "display_container_0.9.0": "{\"dependencies\":[{\"name\":\"either\",\"req\":\"^1.8\"},{\"name\":\"indenter\",\"req\":\"^0.3.3\"}],\"features\":{}}", "displaydoc_0.2.5": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^0.6.1\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1\"},{\"name\":\"syn\",\"req\":\"^2.0\"},{\"kind\":\"dev\",\"name\":\"thiserror\",\"req\":\"^1.0.24\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "doc-comment_0.3.3": "{\"dependencies\":[],\"features\":{\"no_core\":[],\"old_macros\":[]}}", - "document-features_0.2.12": "{\"dependencies\":[{\"name\":\"litrs\",\"req\":\"^1.0.0\"}],\"features\":{\"default\":[],\"self-test\":[]}}", "dotenvy_0.15.7": "{\"dependencies\":[{\"name\":\"clap\",\"optional\":true,\"req\":\"^3.2\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.16.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.3.0\"}],\"features\":{\"cli\":[\"clap\"]}}", "downcast-rs_1.2.1": "{\"dependencies\":[],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "dtor-proc-macro_0.0.6": "{\"dependencies\":[],\"features\":{\"default\":[]}}", @@ -836,7 +834,6 @@ "jni_0.21.1": "{\"dependencies\":[{\"name\":\"cesu8\",\"req\":\"^1.1.0\"},{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"name\":\"combine\",\"req\":\"^4.1.0\"},{\"name\":\"java-locator\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"jni-sys\",\"req\":\"^0.3.0\"},{\"name\":\"libloading\",\"optional\":true,\"req\":\"^0.7\"},{\"name\":\"log\",\"req\":\"^0.4.4\"},{\"name\":\"thiserror\",\"req\":\"^1.0.20\"},{\"kind\":\"dev\",\"name\":\"assert_matches\",\"req\":\"^1.5.0\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rusty-fork\",\"req\":\"^0.3.0\"},{\"kind\":\"build\",\"name\":\"walkdir\",\"req\":\"^2\"},{\"features\":[\"Win32_Globalization\"],\"name\":\"windows-sys\",\"req\":\"^0.45.0\",\"target\":\"cfg(windows)\"},{\"kind\":\"dev\",\"name\":\"bytemuck\",\"req\":\"^1.13.0\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[],\"invocation\":[\"java-locator\",\"libloading\"]}}", "jobserver_0.1.34": "{\"dependencies\":[{\"features\":[\"std\"],\"name\":\"getrandom\",\"req\":\"^0.3.2\",\"target\":\"cfg(windows)\"},{\"name\":\"libc\",\"req\":\"^0.2.171\",\"target\":\"cfg(unix)\"},{\"features\":[\"fs\"],\"kind\":\"dev\",\"name\":\"nix\",\"req\":\"^0.28.0\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.10.1\"}],\"features\":{}}", "js-sys_0.3.77": "{\"dependencies\":[{\"default_features\":false,\"name\":\"once_cell\",\"req\":\"^1.12\"},{\"default_features\":false,\"name\":\"wasm-bindgen\",\"req\":\"=0.2.100\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"wasm-bindgen/std\"]}}", - "kasuari_0.4.11": "{\"dependencies\":[{\"name\":\"document-features\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"hashbrown\",\"req\":\"^0.16\"},{\"default_features\":false,\"features\":[\"require-cas\"],\"name\":\"portable-atomic\",\"optional\":true,\"req\":\"^1.11\"},{\"features\":[\"alloc\"],\"name\":\"portable-atomic-util\",\"optional\":true,\"req\":\"^0.2.4\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.26\"},{\"default_features\":false,\"name\":\"thiserror\",\"req\":\"^2.0\"}],\"features\":{\"default\":[\"std\"],\"document-features\":[\"dep:document-features\"],\"portable-atomic\":[\"dep:portable-atomic\",\"dep:portable-atomic-util\"],\"std\":[\"thiserror/std\",\"portable-atomic?/std\"]}}", "keyring_3.6.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.22\"},{\"name\":\"byteorder\",\"optional\":true,\"req\":\"^1.2\",\"target\":\"cfg(target_os = \\\"windows\\\")\"},{\"features\":[\"derive\",\"wrap_help\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4\"},{\"name\":\"dbus-secret-service\",\"optional\":true,\"req\":\"^4.0.0-rc.1\",\"target\":\"cfg(target_os = \\\"openbsd\\\")\"},{\"name\":\"dbus-secret-service\",\"optional\":true,\"req\":\"^4.0.0-rc.2\",\"target\":\"cfg(target_os = \\\"linux\\\")\"},{\"name\":\"dbus-secret-service\",\"optional\":true,\"req\":\"^4.0.1\",\"target\":\"cfg(target_os = \\\"freebsd\\\")\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11.5\"},{\"kind\":\"dev\",\"name\":\"fastrand\",\"req\":\"^2\"},{\"features\":[\"std\"],\"name\":\"linux-keyutils\",\"optional\":true,\"req\":\"^0.2\",\"target\":\"cfg(target_os = \\\"linux\\\")\"},{\"name\":\"log\",\"req\":\"^0.4.22\"},{\"name\":\"openssl\",\"optional\":true,\"req\":\"^0.10.66\"},{\"kind\":\"dev\",\"name\":\"rpassword\",\"req\":\"^7\"},{\"kind\":\"dev\",\"name\":\"rprompt\",\"req\":\"^2\"},{\"name\":\"secret-service\",\"optional\":true,\"req\":\"^4\",\"target\":\"cfg(target_os = \\\"freebsd\\\")\"},{\"name\":\"secret-service\",\"optional\":true,\"req\":\"^4\",\"target\":\"cfg(target_os = \\\"linux\\\")\"},{\"name\":\"secret-service\",\"optional\":true,\"req\":\"^4\",\"target\":\"cfg(target_os = \\\"openbsd\\\")\"},{\"name\":\"security-framework\",\"optional\":true,\"req\":\"^2\",\"target\":\"cfg(target_os = \\\"ios\\\")\"},{\"name\":\"security-framework\",\"optional\":true,\"req\":\"^3\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"kind\":\"dev\",\"name\":\"whoami\",\"req\":\"^1.5\"},{\"features\":[\"Win32_Foundation\",\"Win32_Security_Credentials\"],\"name\":\"windows-sys\",\"optional\":true,\"req\":\"^0.60\",\"target\":\"cfg(target_os = \\\"windows\\\")\"},{\"name\":\"zbus\",\"optional\":true,\"req\":\"^4\",\"target\":\"cfg(target_os = \\\"freebsd\\\")\"},{\"name\":\"zbus\",\"optional\":true,\"req\":\"^4\",\"target\":\"cfg(target_os = \\\"linux\\\")\"},{\"name\":\"zbus\",\"optional\":true,\"req\":\"^4\",\"target\":\"cfg(target_os = \\\"openbsd\\\")\"},{\"name\":\"zeroize\",\"req\":\"^1.8.1\",\"target\":\"cfg(target_os = \\\"windows\\\")\"}],\"features\":{\"apple-native\":[\"dep:security-framework\"],\"async-io\":[\"zbus?/async-io\"],\"async-secret-service\":[\"dep:secret-service\",\"dep:zbus\"],\"crypto-openssl\":[\"dbus-secret-service?/crypto-openssl\",\"secret-service?/crypto-openssl\"],\"crypto-rust\":[\"dbus-secret-service?/crypto-rust\",\"secret-service?/crypto-rust\"],\"linux-native\":[\"dep:linux-keyutils\"],\"linux-native-async-persistent\":[\"linux-native\",\"async-secret-service\"],\"linux-native-sync-persistent\":[\"linux-native\",\"sync-secret-service\"],\"sync-secret-service\":[\"dep:dbus-secret-service\"],\"tokio\":[\"zbus?/tokio\"],\"vendored\":[\"dbus-secret-service?/vendored\",\"openssl?/vendored\"],\"windows-native\":[\"dep:windows-sys\",\"dep:byteorder\"]}}", "kqueue-sys_1.0.4": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^1.2.1\"},{\"name\":\"libc\",\"req\":\"^0.2.74\"}],\"features\":{}}", "kqueue_1.1.1": "{\"dependencies\":[{\"features\":[\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"dhat\",\"req\":\"^0.3.2\"},{\"name\":\"kqueue-sys\",\"req\":\"^1.0.4\"},{\"name\":\"libc\",\"req\":\"^0.2.17\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1.0\"}],\"features\":{}}", @@ -852,7 +849,6 @@ "linux-raw-sys_0.4.15": "{\"dependencies\":[{\"name\":\"compiler_builtins\",\"optional\":true,\"req\":\"^0.1.49\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.100\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1.0\"}],\"features\":{\"bootparam\":[],\"btrfs\":[],\"default\":[\"std\",\"general\",\"errno\"],\"elf\":[],\"elf_uapi\":[],\"errno\":[],\"general\":[],\"if_arp\":[],\"if_ether\":[],\"if_packet\":[],\"io_uring\":[],\"ioctl\":[],\"landlock\":[],\"loop_device\":[],\"mempolicy\":[],\"net\":[],\"netlink\":[],\"no_std\":[],\"prctl\":[],\"ptrace\":[],\"rustc-dep-of-std\":[\"core\",\"compiler_builtins\",\"no_std\"],\"std\":[],\"system\":[],\"xdp\":[]}}", "linux-raw-sys_0.9.4": "{\"dependencies\":[{\"name\":\"compiler_builtins\",\"optional\":true,\"req\":\"^0.1.49\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.100\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1.0\"}],\"features\":{\"bootparam\":[],\"btrfs\":[],\"default\":[\"std\",\"general\",\"errno\"],\"elf\":[],\"elf_uapi\":[],\"errno\":[],\"general\":[],\"if_arp\":[],\"if_ether\":[],\"if_packet\":[],\"image\":[],\"io_uring\":[],\"ioctl\":[],\"landlock\":[],\"loop_device\":[],\"mempolicy\":[],\"net\":[],\"netlink\":[],\"no_std\":[],\"prctl\":[],\"ptrace\":[],\"rustc-dep-of-std\":[\"core\",\"compiler_builtins\",\"no_std\"],\"std\":[],\"system\":[],\"xdp\":[]}}", "litemap_0.8.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"features\":[\"use-std\"],\"kind\":\"dev\",\"name\":\"postcard\",\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"features\":[\"validation\"],\"kind\":\"dev\",\"name\":\"rkyv\",\"req\":\"^0.7\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.110\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.110\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"yoke\",\"optional\":true,\"req\":\"^0.8.0\"}],\"features\":{\"alloc\":[],\"databake\":[\"dep:databake\"],\"default\":[\"alloc\"],\"serde\":[\"dep:serde\",\"alloc\"],\"testing\":[\"alloc\"],\"yoke\":[\"dep:yoke\"]}}", - "litrs_1.0.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"optional\":true,\"req\":\"^1.0.63\"},{\"name\":\"unicode-xid\",\"optional\":true,\"req\":\"^0.2.4\"}],\"features\":{\"check_suffix\":[\"unicode-xid\"]}}", "local-waker_0.1.4": "{\"dependencies\":[],\"features\":{}}", "lock_api_0.4.13": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"autocfg\",\"req\":\"^1.1.0\"},{\"name\":\"owning_ref\",\"optional\":true,\"req\":\"^0.4.1\"},{\"default_features\":false,\"name\":\"scopeguard\",\"req\":\"^1.1.0\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.126\"}],\"features\":{\"arc_lock\":[],\"atomic_usize\":[],\"default\":[\"atomic_usize\"],\"nightly\":[]}}", "log_0.4.29": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"proc-macro2\",\"req\":\"^1.0.63\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"sval\",\"optional\":true,\"req\":\"^2.16\"},{\"kind\":\"dev\",\"name\":\"sval\",\"req\":\"^2.16\"},{\"kind\":\"dev\",\"name\":\"sval_derive\",\"req\":\"^2.16\"},{\"default_features\":false,\"name\":\"sval_ref\",\"optional\":true,\"req\":\"^2.16\"},{\"default_features\":false,\"features\":[\"inline-i128\"],\"name\":\"value-bag\",\"optional\":true,\"req\":\"^1.12\"},{\"features\":[\"test\"],\"kind\":\"dev\",\"name\":\"value-bag\",\"req\":\"^1.12\"}],\"features\":{\"kv\":[],\"kv_serde\":[\"kv_std\",\"value-bag/serde\",\"serde\"],\"kv_std\":[\"std\",\"kv\",\"value-bag/error\"],\"kv_sval\":[\"kv\",\"value-bag/sval\",\"sval\",\"sval_ref\"],\"kv_unstable\":[\"kv\",\"value-bag\"],\"kv_unstable_serde\":[\"kv_serde\",\"kv_unstable_std\"],\"kv_unstable_std\":[\"kv_std\",\"kv_unstable\"],\"kv_unstable_sval\":[\"kv_sval\",\"kv_unstable\"],\"max_level_debug\":[],\"max_level_error\":[],\"max_level_info\":[],\"max_level_off\":[],\"max_level_trace\":[],\"max_level_warn\":[],\"release_max_level_debug\":[],\"release_max_level_error\":[],\"release_max_level_info\":[],\"release_max_level_off\":[],\"release_max_level_trace\":[],\"release_max_level_warn\":[],\"serde\":[\"serde_core\"],\"std\":[]}}", @@ -983,7 +979,6 @@ "rand_core_0.6.4": "{\"dependencies\":[{\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.2\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"alloc\":[],\"serde1\":[\"serde\"],\"std\":[\"alloc\",\"getrandom\",\"getrandom/std\"]}}", "rand_core_0.9.3": "{\"dependencies\":[{\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.3.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"os_rng\":[\"dep:getrandom\"],\"serde\":[\"dep:serde\"],\"std\":[\"getrandom?/std\"]}}", "rand_xorshift_0.4.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1\"},{\"name\":\"rand_core\",\"req\":\"^0.9.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.118\"}],\"features\":{\"serde\":[\"dep:serde\"]}}", - "ratatui-core_0.1.0": "{\"dependencies\":[{\"name\":\"anstyle\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"bitflags\",\"req\":\"^2.10\"},{\"default_features\":false,\"name\":\"compact_str\",\"req\":\"^0.9\"},{\"name\":\"document-features\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"hashbrown\",\"req\":\"^0.16\"},{\"name\":\"indoc\",\"req\":\"^2\"},{\"default_features\":false,\"features\":[\"use_alloc\"],\"name\":\"itertools\",\"req\":\"^0.14\"},{\"default_features\":false,\"name\":\"kasuari\",\"req\":\"^0.4\"},{\"name\":\"lru\",\"req\":\"^0.16\"},{\"name\":\"palette\",\"optional\":true,\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.26\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"strum\",\"req\":\"^0.27\"},{\"default_features\":false,\"name\":\"thiserror\",\"req\":\"^2\"},{\"name\":\"unicode-segmentation\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"unicode-truncate\",\"req\":\"^2\"},{\"name\":\"unicode-width\",\"req\":\">=0.2.0, <=0.2.2\"}],\"features\":{\"anstyle\":[\"dep:anstyle\"],\"default\":[],\"layout-cache\":[\"std\"],\"palette\":[\"std\",\"dep:palette\"],\"portable-atomic\":[\"kasuari/portable-atomic\"],\"scrolling-regions\":[],\"serde\":[\"std\",\"dep:serde\",\"bitflags/serde\",\"compact_str/serde\"],\"std\":[\"itertools/use_std\",\"thiserror/std\",\"kasuari/std\",\"compact_str/std\",\"unicode-truncate/std\",\"strum/std\"],\"underline-color\":[]}}", "ratatui-macros_0.6.0": "{\"dependencies\":[{\"features\":[\"user-hooks\"],\"kind\":\"dev\",\"name\":\"cargo-husky\",\"req\":\"^1.5.0\"},{\"name\":\"ratatui\",\"req\":\"^0.29.0\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.101\"}],\"features\":{}}", "redox_syscall_0.5.15": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2.4\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"loom\",\"req\":\"^0.7\",\"target\":\"cfg(loom)\"}],\"features\":{\"default\":[\"userspace\"],\"rustc-dep-of-std\":[\"core\",\"bitflags/rustc-dep-of-std\"],\"std\":[],\"userspace\":[]}}", "redox_users_0.4.6": "{\"dependencies\":[{\"features\":[\"std\"],\"name\":\"getrandom\",\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"std\",\"call\"],\"name\":\"libredox\",\"req\":\"^0.1.3\"},{\"name\":\"rust-argon2\",\"optional\":true,\"req\":\"^0.8\"},{\"name\":\"thiserror\",\"req\":\"^1.0\"},{\"features\":[\"zeroize_derive\"],\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.4\"}],\"features\":{\"auth\":[\"rust-argon2\",\"zeroize\"],\"default\":[\"auth\"]}}", @@ -1162,7 +1157,6 @@ "try-lock_0.2.5": "{\"dependencies\":[],\"features\":{}}", "ts-rs-macros_11.1.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"full\",\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2.0.28\"},{\"name\":\"termcolor\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"no-serde-warnings\":[],\"serde-compat\":[\"termcolor\"]}}", "ts-rs_11.1.0": "{\"dependencies\":[{\"features\":[\"serde\"],\"name\":\"bigdecimal\",\"optional\":true,\"req\":\">=0.0.13, <0.5\"},{\"name\":\"bson\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4\"},{\"features\":[\"serde\"],\"kind\":\"dev\",\"name\":\"chrono\",\"req\":\"^0.4\"},{\"name\":\"dprint-plugin-typescript\",\"optional\":true,\"req\":\"=0.95\"},{\"name\":\"heapless\",\"optional\":true,\"req\":\">=0.7, <0.9\"},{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"ordered-float\",\"optional\":true,\"req\":\">=3, <6\"},{\"name\":\"semver\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"name\":\"smol_str\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"sync\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.40\"},{\"name\":\"ts-rs-macros\",\"req\":\"=11.1.0\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"bigdecimal-impl\":[\"bigdecimal\"],\"bson-uuid-impl\":[\"bson\"],\"bytes-impl\":[\"bytes\"],\"chrono-impl\":[\"chrono\"],\"default\":[\"serde-compat\"],\"format\":[\"dprint-plugin-typescript\"],\"heapless-impl\":[\"heapless\"],\"import-esm\":[],\"indexmap-impl\":[\"indexmap\"],\"no-serde-warnings\":[\"ts-rs-macros/no-serde-warnings\"],\"ordered-float-impl\":[\"ordered-float\"],\"semver-impl\":[\"semver\"],\"serde-compat\":[\"ts-rs-macros/serde-compat\"],\"serde-json-impl\":[\"serde_json\"],\"smol_str-impl\":[\"smol_str\"],\"tokio-impl\":[\"tokio\"],\"url-impl\":[\"url\"],\"uuid-impl\":[\"uuid\"]}}", - "tui-scrollbar_0.2.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"color-eyre\",\"req\":\"^0.6\"},{\"name\":\"crossterm_0_28\",\"optional\":true,\"package\":\"crossterm\",\"req\":\"^0.28\"},{\"name\":\"crossterm_0_29\",\"optional\":true,\"package\":\"crossterm\",\"req\":\"^0.29\"},{\"name\":\"document-features\",\"req\":\"^0.2.11\"},{\"kind\":\"dev\",\"name\":\"ratatui\",\"req\":\"^0.30.0\"},{\"name\":\"ratatui-core\",\"req\":\"^0.1\"}],\"features\":{\"crossterm\":[\"crossterm_0_29\"],\"crossterm_0_28\":[\"dep:crossterm_0_28\"],\"crossterm_0_29\":[\"dep:crossterm_0_29\"],\"default\":[]}}", "typenum_1.18.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"scale-info\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"const-generics\":[],\"force_unix_path_separator\":[],\"i128\":[],\"no_std\":[],\"scale_info\":[\"scale-info/derive\"],\"strict\":[]}}", "uds_windows_1.1.0": "{\"dependencies\":[{\"name\":\"memoffset\",\"req\":\"^0.9.0\"},{\"name\":\"tempfile\",\"req\":\"^3\",\"target\":\"cfg(windows)\"},{\"features\":[\"winsock2\",\"ws2def\",\"minwinbase\",\"ntdef\",\"processthreadsapi\",\"handleapi\",\"ws2tcpip\",\"winbase\"],\"name\":\"winapi\",\"req\":\"^0.3.9\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "uname_0.1.1": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2\"}],\"features\":{}}", @@ -1172,7 +1166,6 @@ "unicode-linebreak_0.1.5": "{\"dependencies\":[],\"features\":{}}", "unicode-segmentation_1.12.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^0.7\"}],\"features\":{\"no_std\":[]}}", "unicode-truncate_1.1.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"itertools\",\"req\":\"^0.13\"},{\"default_features\":false,\"name\":\"unicode-segmentation\",\"req\":\"^1\"},{\"name\":\"unicode-width\",\"req\":\"^0.1\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", - "unicode-truncate_2.0.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"itertools\",\"req\":\"^0.13\"},{\"default_features\":false,\"name\":\"unicode-segmentation\",\"req\":\"^1\"},{\"name\":\"unicode-width\",\"req\":\"^0.2\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "unicode-width_0.1.14": "{\"dependencies\":[{\"name\":\"compiler_builtins\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0\"},{\"name\":\"std\",\"optional\":true,\"package\":\"rustc-std-workspace-std\",\"req\":\"^1.0\"}],\"features\":{\"cjk\":[],\"default\":[\"cjk\"],\"no_std\":[],\"rustc-dep-of-std\":[\"std\",\"core\",\"compiler_builtins\"]}}", "unicode-width_0.2.1": "{\"dependencies\":[{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0\"},{\"name\":\"std\",\"optional\":true,\"package\":\"rustc-std-workspace-std\",\"req\":\"^1.0\"}],\"features\":{\"cjk\":[],\"default\":[\"cjk\"],\"no_std\":[],\"rustc-dep-of-std\":[\"std\",\"core\"]}}", "unicode-xid_0.2.6": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"}],\"features\":{\"bench\":[],\"default\":[],\"no_std\":[]}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 6fbf269d0d4..1cadf2f071f 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1166,8 +1166,6 @@ dependencies = [ "codex-rmcp-client", "codex-stdio-to-uds", "codex-tui", - "codex-tui2", - "codex-utils-absolute-path", "codex-utils-cargo-bin", "codex-windows-sandbox", "libc", @@ -1811,81 +1809,6 @@ dependencies = [ "winsplit", ] -[[package]] -name = "codex-tui2" -version = "0.0.0" -dependencies = [ - "anyhow", - "arboard", - "assert_matches", - "async-stream", - "base64", - "chrono", - "clap", - "codex-ansi-escape", - "codex-app-server-protocol", - "codex-arg0", - "codex-backend-client", - "codex-cli", - "codex-common", - "codex-core", - "codex-feedback", - "codex-file-search", - "codex-login", - "codex-otel", - "codex-protocol", - "codex-tui", - "codex-utils-absolute-path", - "codex-utils-cargo-bin", - "codex-utils-pty", - "codex-windows-sandbox", - "color-eyre", - "crossterm", - "derive_more 2.1.1", - "diffy", - "dirs", - "dunce", - "image", - "insta", - "itertools 0.14.0", - "lazy_static", - "libc", - "mcp-types", - "pathdiff", - "pretty_assertions", - "pulldown-cmark", - "rand 0.9.2", - "ratatui", - "ratatui-core", - "ratatui-macros", - "regex-lite", - "reqwest", - "serde", - "serde_json", - "serial_test", - "shlex", - "strum 0.27.2", - "strum_macros 0.27.2", - "supports-color 3.0.2", - "tempfile", - "textwrap 0.16.2", - "tokio", - "tokio-stream", - "tokio-util", - "toml 0.9.5", - "tracing", - "tracing-appender", - "tracing-subscriber", - "tree-sitter-bash", - "tree-sitter-highlight", - "tui-scrollbar", - "unicode-segmentation", - "unicode-width 0.2.1", - "url", - "uuid", - "vt100", -] - [[package]] name = "codex-utils-absolute-path" version = "0.0.0" @@ -2048,20 +1971,6 @@ dependencies = [ "static_assertions", ] -[[package]] -name = "compact_str" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" -dependencies = [ - "castaway", - "cfg-if", - "itoa", - "rustversion", - "ryu", - "static_assertions", -] - [[package]] name = "concurrent-queue" version = "2.5.0" @@ -2648,15 +2557,6 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" -[[package]] -name = "document-features" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" -dependencies = [ - "litrs", -] - [[package]] name = "dotenvy" version = "0.15.7" @@ -3988,16 +3888,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "kasuari" -version = "0.4.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fe90c1150662e858c7d5f945089b7517b0a80d8bf7ba4b1b5ffc984e7230a5b" -dependencies = [ - "hashbrown 0.16.0", - "thiserror 2.0.17", -] - [[package]] name = "keyring" version = "3.6.3" @@ -4143,12 +4033,6 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" -[[package]] -name = "litrs" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" - [[package]] name = "local-waker" version = "0.1.4" @@ -5488,7 +5372,7 @@ source = "git+https://github.com/nornagon/ratatui?branch=nornagon-v0.29.0-patch# dependencies = [ "bitflags 2.10.0", "cassowary", - "compact_str 0.8.1", + "compact_str", "crossterm", "indoc", "instability", @@ -5497,27 +5381,7 @@ dependencies = [ "paste", "strum 0.26.3", "unicode-segmentation", - "unicode-truncate 1.1.0", - "unicode-width 0.2.1", -] - -[[package]] -name = "ratatui-core" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" -dependencies = [ - "bitflags 2.10.0", - "compact_str 0.9.0", - "hashbrown 0.16.0", - "indoc", - "itertools 0.14.0", - "kasuari", - "lru 0.16.3", - "strum 0.27.2", - "thiserror 2.0.17", - "unicode-segmentation", - "unicode-truncate 2.0.0", + "unicode-truncate", "unicode-width 0.2.1", ] @@ -6684,9 +6548,6 @@ name = "strum" version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" -dependencies = [ - "strum_macros 0.27.2", -] [[package]] name = "strum_macros" @@ -7549,16 +7410,6 @@ dependencies = [ "termcolor", ] -[[package]] -name = "tui-scrollbar" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e4267311b5c7999a996ea94939b6d2b1b44a9e5cc11e76cbbb6dcca4c281df4" -dependencies = [ - "document-features", - "ratatui-core", -] - [[package]] name = "tungstenite" version = "0.28.0" @@ -7644,17 +7495,6 @@ dependencies = [ "unicode-width 0.1.14", ] -[[package]] -name = "unicode-truncate" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fbf03860ff438702f3910ca5f28f8dac63c1c11e7efb5012b8b175493606330" -dependencies = [ - "itertools 0.13.0", - "unicode-segmentation", - "unicode-width 0.2.1", -] - [[package]] name = "unicode-width" version = "0.1.14" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 96df92b81ee..99da3785601 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -35,7 +35,6 @@ members = [ "stdio-to-uds", "otel", "tui", - "tui2", "utils/absolute-path", "utils/cargo-bin", "utils/git", @@ -93,7 +92,6 @@ codex-responses-api-proxy = { path = "responses-api-proxy" } codex-rmcp-client = { path = "rmcp-client" } codex-stdio-to-uds = { path = "stdio-to-uds" } codex-tui = { path = "tui" } -codex-tui2 = { path = "tui2" } codex-utils-absolute-path = { path = "utils/absolute-path" } codex-utils-cache = { path = "utils/cache" } codex-utils-cargo-bin = { path = "utils/cargo-bin" } @@ -178,7 +176,6 @@ pretty_assertions = "1.4.1" pulldown-cmark = "0.10" rand = "0.9" ratatui = "0.29.0" -ratatui-core = "0.1.0" ratatui-macros = "0.6.0" regex = "1.12.2" regex-lite = "0.1.8" @@ -225,7 +222,6 @@ tree-sitter-bash = "0.25" zstd = "0.13" tree-sitter-highlight = "0.25.10" ts-rs = "11" -tui-scrollbar = "0.2.2" uds_windows = "1.1.0" unicode-segmentation = "1.12.0" unicode-width = "0.2" diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 1bd36e56169..81fcd35a912 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -35,8 +35,6 @@ codex-responses-api-proxy = { workspace = true } codex-rmcp-client = { workspace = true } codex-stdio-to-uds = { workspace = true } codex-tui = { workspace = true } -codex-tui2 = { workspace = true } -codex-utils-absolute-path = { workspace = true } libc = { workspace = true } owo-colors = { workspace = true } regex-lite = { workspace = true } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index dc4f648a4fc..d620b8ab355 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -26,7 +26,6 @@ use codex_tui::AppExitInfo; use codex_tui::Cli as TuiCli; use codex_tui::ExitReason; use codex_tui::update_action::UpdateAction; -use codex_tui2 as tui2; use owo_colors::OwoColorize; use std::io::IsTerminal; use std::path::PathBuf; @@ -40,14 +39,8 @@ use crate::mcp_cmd::McpCli; use codex_core::config::Config; use codex_core::config::ConfigOverrides; -use codex_core::config::find_codex_home; -use codex_core::config::load_config_as_toml_with_cli_overrides; -use codex_core::features::Feature; -use codex_core::features::FeatureOverrides; -use codex_core::features::Features; use codex_core::features::is_known_feature_key; use codex_core::terminal::TerminalName; -use codex_utils_absolute_path::AbsolutePathBuf; /// Codex CLI /// @@ -727,8 +720,6 @@ fn prepend_config_flags( .splice(0..0, cli_config_overrides.raw_overrides); } -/// Run the interactive Codex TUI, dispatching to either the legacy implementation or the -/// experimental TUI v2 shim based on feature flags resolved from config. async fn run_interactive_tui( mut interactive: TuiCli, codex_linux_sandbox_exe: Option, @@ -756,12 +747,7 @@ async fn run_interactive_tui( } } - if is_tui2_enabled(&interactive).await? { - let result = tui2::run_main(interactive.into(), codex_linux_sandbox_exe).await?; - Ok(result.into()) - } else { - codex_tui::run_main(interactive, codex_linux_sandbox_exe).await - } + codex_tui::run_main(interactive, codex_linux_sandbox_exe).await } fn confirm(prompt: &str) -> std::io::Result { @@ -773,32 +759,6 @@ fn confirm(prompt: &str) -> std::io::Result { Ok(answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes")) } -/// Returns `Ok(true)` when the resolved configuration enables the `tui2` feature flag. -/// -/// This performs a lightweight config load (honoring the same precedence as the lower-level TUI -/// bootstrap: `$CODEX_HOME`, config.toml, profile, and CLI `-c` overrides) solely to decide which -/// TUI frontend to launch. The full configuration is still loaded later by the interactive TUI. -async fn is_tui2_enabled(cli: &TuiCli) -> std::io::Result { - let raw_overrides = cli.config_overrides.raw_overrides.clone(); - let overrides_cli = codex_common::CliConfigOverrides { raw_overrides }; - let cli_kv_overrides = overrides_cli - .parse_overrides() - .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; - - let codex_home = find_codex_home()?; - let cwd = cli.cwd.clone(); - let config_cwd = match cwd.as_deref() { - Some(path) => AbsolutePathBuf::from_absolute_path(path)?, - None => AbsolutePathBuf::current_dir()?, - }; - let config_toml = - load_config_as_toml_with_cli_overrides(&codex_home, &config_cwd, cli_kv_overrides).await?; - let config_profile = config_toml.get_config_profile(cli.config_profile.clone())?; - let overrides = FeatureOverrides::default(); - let features = Features::from_config(&config_toml, &config_profile, overrides); - Ok(features.enabled(Feature::Tui2)) -} - /// Build the final `TuiCli` for a `codex resume` invocation. fn finalize_resume_interactive( mut interactive: TuiCli, diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 92e36bd79e0..e70841c7d29 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -198,9 +198,6 @@ "steer": { "type": "boolean" }, - "tui2": { - "type": "boolean" - }, "undo": { "type": "boolean" }, @@ -826,32 +823,6 @@ }, "type": "object" }, - "ScrollInputMode": { - "description": "How TUI2 should interpret mouse scroll events.\n\nTerminals generally encode both mouse wheels and trackpads as the same \"scroll up/down\" mouse button events, without a magnitude. This setting controls whether Codex uses a heuristic to infer wheel vs trackpad per stream, or forces a specific behavior.", - "oneOf": [ - { - "description": "Infer wheel vs trackpad behavior per scroll stream.", - "enum": [ - "auto" - ], - "type": "string" - }, - { - "description": "Always treat scroll events as mouse-wheel input (fixed lines per tick).", - "enum": [ - "wheel" - ], - "type": "string" - }, - { - "description": "Always treat scroll events as trackpad input (fractional accumulation).", - "enum": [ - "trackpad" - ], - "type": "string" - } - ] - }, "ShellEnvironmentPolicyInherit": { "oneOf": [ { @@ -991,62 +962,6 @@ "default": true, "description": "Enable desktop notifications from the TUI when the terminal is unfocused. Defaults to `true`." }, - "scroll_events_per_tick": { - "description": "Override the *wheel* event density used to normalize TUI2 scrolling.\n\nTerminals generally deliver both mouse wheels and trackpads as discrete `scroll up/down` mouse events with direction but no magnitude. Unfortunately, the *number* of raw events per physical wheel notch varies by terminal (commonly 1, 3, or 9+). TUI2 uses this value to normalize that raw event density into consistent \"wheel tick\" behavior.\n\nWheel math (conceptually):\n\n- A single event contributes `1 / scroll_events_per_tick` tick-equivalents. - Wheel-like streams then scale that by `scroll_wheel_lines` so one physical notch scrolls a fixed number of lines.\n\nTrackpad math is intentionally *not* fully tied to this value: in trackpad-like mode, TUI2 uses `min(scroll_events_per_tick, 3)` as the divisor so terminals with dense wheel ticks (e.g. 9 events per notch) do not make trackpads feel artificially slow.\n\nDefaults are derived per terminal from [`crate::terminal::TerminalInfo`] when TUI2 starts. See `codex-rs/tui2/docs/scroll_input_model.md` for the probe data and rationale.", - "format": "uint16", - "minimum": 0.0, - "type": "integer" - }, - "scroll_invert": { - "default": false, - "description": "Invert mouse scroll direction in TUI2.\n\nThis flips the scroll sign after terminal detection. It is applied consistently to both wheel and trackpad input.", - "type": "boolean" - }, - "scroll_mode": { - "allOf": [ - { - "$ref": "#/definitions/ScrollInputMode" - } - ], - "default": "auto", - "description": "Select how TUI2 interprets mouse scroll input.\n\n- `auto` (default): infer wheel vs trackpad per scroll stream. - `wheel`: always use wheel behavior (fixed lines per wheel notch). - `trackpad`: always use trackpad behavior (fractional accumulation; wheel may feel slow)." - }, - "scroll_trackpad_accel_events": { - "description": "Trackpad acceleration: approximate number of events required to gain +1x speed in TUI2.\n\nThis keeps small swipes precise while allowing large/faster swipes to cover more content. Defaults are chosen to address terminals where trackpad event density is comparatively low.\n\nConcretely, TUI2 computes an acceleration multiplier for trackpad-like streams:\n\n- `multiplier = clamp(1 + abs(events) / scroll_trackpad_accel_events, 1..scroll_trackpad_accel_max)`\n\nThe multiplier is applied to the stream’s computed line delta (including any carried fractional remainder).", - "format": "uint16", - "minimum": 0.0, - "type": "integer" - }, - "scroll_trackpad_accel_max": { - "description": "Trackpad acceleration: maximum multiplier applied to trackpad-like streams.\n\nSet to 1 to effectively disable trackpad acceleration.\n\nSee [`Tui::scroll_trackpad_accel_events`] for the exact multiplier formula.", - "format": "uint16", - "minimum": 0.0, - "type": "integer" - }, - "scroll_trackpad_lines": { - "description": "Override baseline trackpad scroll sensitivity in TUI2.\n\nTrackpads do not have discrete notches, but terminals still emit discrete `scroll up/down` events. In trackpad-like mode, TUI2 accumulates fractional scroll and only applies whole lines to the viewport.\n\nTrackpad per-event contribution is:\n\n- `scroll_trackpad_lines / min(scroll_events_per_tick, 3)`\n\n(plus optional bounded acceleration; see `scroll_trackpad_accel_*`). The `min(..., 3)` divisor is deliberate: `scroll_events_per_tick` is calibrated from *wheel* behavior and can be much larger than trackpad event density, which would otherwise make trackpads feel too slow in dense-wheel terminals.\n\nDefaults to 1, meaning one tick-equivalent maps to one transcript line.", - "format": "uint16", - "minimum": 0.0, - "type": "integer" - }, - "scroll_wheel_like_max_duration_ms": { - "description": "Auto-mode fallback: maximum duration (ms) that a very small stream is still treated as wheel-like.\n\nThis is only used when `scroll_events_per_tick` is effectively 1 (one event per wheel notch). In that case, we cannot observe a \"tick completion time\", so TUI2 treats a short-lived, small stream (<= 2 events) as wheel-like to preserve classic wheel behavior.", - "format": "uint64", - "minimum": 0.0, - "type": "integer" - }, - "scroll_wheel_lines": { - "description": "Override how many transcript lines one physical *wheel notch* should scroll in TUI2.\n\nThis is the \"classic feel\" knob. Defaults to 3.\n\nWheel-like per-event contribution is `scroll_wheel_lines / scroll_events_per_tick`. For example, in a terminal that emits 9 events per notch, the default `3 / 9` yields 1/3 of a line per event and totals 3 lines once the full notch burst arrives.\n\nSee `codex-rs/tui2/docs/scroll_input_model.md` for details on the stream model and the wheel/trackpad heuristic.", - "format": "uint16", - "minimum": 0.0, - "type": "integer" - }, - "scroll_wheel_tick_detect_max_ms": { - "description": "Auto-mode threshold: maximum time (ms) for the first tick-worth of events to arrive.\n\nIn `scroll_mode = \"auto\"`, TUI2 starts a stream as trackpad-like (to avoid overshoot) and promotes it to wheel-like if `scroll_events_per_tick` events arrive \"quickly enough\". This threshold controls what \"quickly enough\" means.\n\nMost users should leave this unset; it is primarily for terminals that emit wheel ticks batched over longer time spans.", - "format": "uint64", - "minimum": 0.0, - "type": "integer" - }, "show_tooltips": { "default": true, "description": "Show startup tooltips in the TUI welcome screen. Defaults to `true`.", @@ -1246,9 +1161,6 @@ "steer": { "type": "boolean" }, - "tui2": { - "type": "boolean" - }, "undo": { "type": "boolean" }, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 1e409559e63..9d06eca30bd 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -13,7 +13,6 @@ use crate::config::types::OtelConfigToml; use crate::config::types::OtelExporterKind; use crate::config::types::Personality; use crate::config::types::SandboxWorkspaceWrite; -use crate::config::types::ScrollInputMode; use crate::config::types::ShellEnvironmentPolicy; use crate::config::types::ShellEnvironmentPolicyToml; use crate::config::types::SkillsConfig; @@ -200,58 +199,6 @@ pub struct Config { /// Show startup tooltips in the TUI welcome screen. pub show_tooltips: bool, - /// Override the events-per-wheel-tick factor for TUI2 scroll normalization. - /// - /// This is the same `tui.scroll_events_per_tick` value from `config.toml`, plumbed through the - /// merged [`Config`] object (see [`Tui`]) so TUI2 can normalize scroll event density per - /// terminal. - pub tui_scroll_events_per_tick: Option, - - /// Override the number of lines applied per wheel tick in TUI2. - /// - /// This is the same `tui.scroll_wheel_lines` value from `config.toml` (see [`Tui`]). TUI2 - /// applies it to wheel-like scroll streams. Trackpad-like scrolling uses a separate - /// `tui.scroll_trackpad_lines` setting. - pub tui_scroll_wheel_lines: Option, - - /// Override the number of lines per tick-equivalent used for trackpad scrolling in TUI2. - /// - /// This is the same `tui.scroll_trackpad_lines` value from `config.toml` (see [`Tui`]). - pub tui_scroll_trackpad_lines: Option, - - /// Trackpad acceleration: approximate number of events required to gain +1x speed in TUI2. - /// - /// This is the same `tui.scroll_trackpad_accel_events` value from `config.toml` (see [`Tui`]). - pub tui_scroll_trackpad_accel_events: Option, - - /// Trackpad acceleration: maximum multiplier applied to trackpad-like streams in TUI2. - /// - /// This is the same `tui.scroll_trackpad_accel_max` value from `config.toml` (see [`Tui`]). - pub tui_scroll_trackpad_accel_max: Option, - - /// Control how TUI2 interprets mouse scroll input (wheel vs trackpad). - /// - /// This is the same `tui.scroll_mode` value from `config.toml` (see [`Tui`]). - pub tui_scroll_mode: ScrollInputMode, - - /// Override the wheel tick detection threshold (ms) for TUI2 auto scroll mode. - /// - /// This is the same `tui.scroll_wheel_tick_detect_max_ms` value from `config.toml` (see - /// [`Tui`]). - pub tui_scroll_wheel_tick_detect_max_ms: Option, - - /// Override the wheel-like end-of-stream threshold (ms) for TUI2 auto scroll mode. - /// - /// This is the same `tui.scroll_wheel_like_max_duration_ms` value from `config.toml` (see - /// [`Tui`]). - pub tui_scroll_wheel_like_max_duration_ms: Option, - - /// Invert mouse scroll direction for TUI2. - /// - /// This is the same `tui.scroll_invert` value from `config.toml` (see [`Tui`]) and is applied - /// consistently to both mouse wheels and trackpads. - pub tui_scroll_invert: bool, - /// Controls whether the TUI uses the terminal's alternate screen buffer. /// /// This is the same `tui.alternate_screen` value from `config.toml` (see [`Tui`]). @@ -1613,27 +1560,6 @@ impl Config { .unwrap_or_default(), animations: cfg.tui.as_ref().map(|t| t.animations).unwrap_or(true), show_tooltips: cfg.tui.as_ref().map(|t| t.show_tooltips).unwrap_or(true), - tui_scroll_events_per_tick: cfg.tui.as_ref().and_then(|t| t.scroll_events_per_tick), - tui_scroll_wheel_lines: cfg.tui.as_ref().and_then(|t| t.scroll_wheel_lines), - tui_scroll_trackpad_lines: cfg.tui.as_ref().and_then(|t| t.scroll_trackpad_lines), - tui_scroll_trackpad_accel_events: cfg - .tui - .as_ref() - .and_then(|t| t.scroll_trackpad_accel_events), - tui_scroll_trackpad_accel_max: cfg - .tui - .as_ref() - .and_then(|t| t.scroll_trackpad_accel_max), - tui_scroll_mode: cfg.tui.as_ref().map(|t| t.scroll_mode).unwrap_or_default(), - tui_scroll_wheel_tick_detect_max_ms: cfg - .tui - .as_ref() - .and_then(|t| t.scroll_wheel_tick_detect_max_ms), - tui_scroll_wheel_like_max_duration_ms: cfg - .tui - .as_ref() - .and_then(|t| t.scroll_wheel_like_max_duration_ms), - tui_scroll_invert: cfg.tui.as_ref().map(|t| t.scroll_invert).unwrap_or(false), tui_alternate_screen: cfg .tui .as_ref() @@ -1886,15 +1812,6 @@ persistence = "none" notifications: Notifications::Enabled(true), animations: true, show_tooltips: true, - scroll_events_per_tick: None, - scroll_wheel_lines: None, - scroll_trackpad_lines: None, - scroll_trackpad_accel_events: None, - scroll_trackpad_accel_max: None, - scroll_mode: ScrollInputMode::Auto, - scroll_wheel_tick_detect_max_ms: None, - scroll_wheel_like_max_duration_ms: None, - scroll_invert: false, alternate_screen: AltScreenMode::Auto, } ); @@ -3782,15 +3699,6 @@ model_verbosity = "high" show_tooltips: true, analytics_enabled: Some(true), feedback_enabled: true, - tui_scroll_events_per_tick: None, - tui_scroll_wheel_lines: None, - tui_scroll_trackpad_lines: None, - tui_scroll_trackpad_accel_events: None, - tui_scroll_trackpad_accel_max: None, - tui_scroll_mode: ScrollInputMode::Auto, - tui_scroll_wheel_tick_detect_max_ms: None, - tui_scroll_wheel_like_max_duration_ms: None, - tui_scroll_invert: false, tui_alternate_screen: AltScreenMode::Auto, otel: OtelConfig::default(), }, @@ -3871,15 +3779,6 @@ model_verbosity = "high" show_tooltips: true, analytics_enabled: Some(true), feedback_enabled: true, - tui_scroll_events_per_tick: None, - tui_scroll_wheel_lines: None, - tui_scroll_trackpad_lines: None, - tui_scroll_trackpad_accel_events: None, - tui_scroll_trackpad_accel_max: None, - tui_scroll_mode: ScrollInputMode::Auto, - tui_scroll_wheel_tick_detect_max_ms: None, - tui_scroll_wheel_like_max_duration_ms: None, - tui_scroll_invert: false, tui_alternate_screen: AltScreenMode::Auto, otel: OtelConfig::default(), }; @@ -3975,15 +3874,6 @@ model_verbosity = "high" show_tooltips: true, analytics_enabled: Some(false), feedback_enabled: true, - tui_scroll_events_per_tick: None, - tui_scroll_wheel_lines: None, - tui_scroll_trackpad_lines: None, - tui_scroll_trackpad_accel_events: None, - tui_scroll_trackpad_accel_max: None, - tui_scroll_mode: ScrollInputMode::Auto, - tui_scroll_wheel_tick_detect_max_ms: None, - tui_scroll_wheel_like_max_duration_ms: None, - tui_scroll_invert: false, tui_alternate_screen: AltScreenMode::Auto, otel: OtelConfig::default(), }; @@ -4065,15 +3955,6 @@ model_verbosity = "high" show_tooltips: true, analytics_enabled: Some(true), feedback_enabled: true, - tui_scroll_events_per_tick: None, - tui_scroll_wheel_lines: None, - tui_scroll_trackpad_lines: None, - tui_scroll_trackpad_accel_events: None, - tui_scroll_trackpad_accel_max: None, - tui_scroll_mode: ScrollInputMode::Auto, - tui_scroll_wheel_tick_detect_max_ms: None, - tui_scroll_wheel_like_max_duration_ms: None, - tui_scroll_invert: false, tui_alternate_screen: AltScreenMode::Auto, otel: OtelConfig::default(), }; diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index 78413672461..3e83e586e1f 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -418,23 +418,6 @@ impl Default for Notifications { } } -/// How TUI2 should interpret mouse scroll events. -/// -/// Terminals generally encode both mouse wheels and trackpads as the same "scroll up/down" mouse -/// button events, without a magnitude. This setting controls whether Codex uses a heuristic to -/// infer wheel vs trackpad per stream, or forces a specific behavior. -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, Default)] -#[serde(rename_all = "snake_case")] -pub enum ScrollInputMode { - /// Infer wheel vs trackpad behavior per scroll stream. - #[default] - Auto, - /// Always treat scroll events as mouse-wheel input (fixed lines per tick). - Wheel, - /// Always treat scroll events as trackpad input (fractional accumulation). - Trackpad, -} - /// Collection of settings that are specific to the TUI. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] #[schemars(deny_unknown_fields)] @@ -454,109 +437,6 @@ pub struct Tui { #[serde(default = "default_true")] pub show_tooltips: bool, - /// Override the *wheel* event density used to normalize TUI2 scrolling. - /// - /// Terminals generally deliver both mouse wheels and trackpads as discrete `scroll up/down` - /// mouse events with direction but no magnitude. Unfortunately, the *number* of raw events - /// per physical wheel notch varies by terminal (commonly 1, 3, or 9+). TUI2 uses this value - /// to normalize that raw event density into consistent "wheel tick" behavior. - /// - /// Wheel math (conceptually): - /// - /// - A single event contributes `1 / scroll_events_per_tick` tick-equivalents. - /// - Wheel-like streams then scale that by `scroll_wheel_lines` so one physical notch scrolls - /// a fixed number of lines. - /// - /// Trackpad math is intentionally *not* fully tied to this value: in trackpad-like mode, TUI2 - /// uses `min(scroll_events_per_tick, 3)` as the divisor so terminals with dense wheel ticks - /// (e.g. 9 events per notch) do not make trackpads feel artificially slow. - /// - /// Defaults are derived per terminal from [`crate::terminal::TerminalInfo`] when TUI2 starts. - /// See `codex-rs/tui2/docs/scroll_input_model.md` for the probe data and rationale. - pub scroll_events_per_tick: Option, - - /// Override how many transcript lines one physical *wheel notch* should scroll in TUI2. - /// - /// This is the "classic feel" knob. Defaults to 3. - /// - /// Wheel-like per-event contribution is `scroll_wheel_lines / scroll_events_per_tick`. For - /// example, in a terminal that emits 9 events per notch, the default `3 / 9` yields 1/3 of a - /// line per event and totals 3 lines once the full notch burst arrives. - /// - /// See `codex-rs/tui2/docs/scroll_input_model.md` for details on the stream model and the - /// wheel/trackpad heuristic. - pub scroll_wheel_lines: Option, - - /// Override baseline trackpad scroll sensitivity in TUI2. - /// - /// Trackpads do not have discrete notches, but terminals still emit discrete `scroll up/down` - /// events. In trackpad-like mode, TUI2 accumulates fractional scroll and only applies whole - /// lines to the viewport. - /// - /// Trackpad per-event contribution is: - /// - /// - `scroll_trackpad_lines / min(scroll_events_per_tick, 3)` - /// - /// (plus optional bounded acceleration; see `scroll_trackpad_accel_*`). The `min(..., 3)` - /// divisor is deliberate: `scroll_events_per_tick` is calibrated from *wheel* behavior and - /// can be much larger than trackpad event density, which would otherwise make trackpads feel - /// too slow in dense-wheel terminals. - /// - /// Defaults to 1, meaning one tick-equivalent maps to one transcript line. - pub scroll_trackpad_lines: Option, - - /// Trackpad acceleration: approximate number of events required to gain +1x speed in TUI2. - /// - /// This keeps small swipes precise while allowing large/faster swipes to cover more content. - /// Defaults are chosen to address terminals where trackpad event density is comparatively low. - /// - /// Concretely, TUI2 computes an acceleration multiplier for trackpad-like streams: - /// - /// - `multiplier = clamp(1 + abs(events) / scroll_trackpad_accel_events, 1..scroll_trackpad_accel_max)` - /// - /// The multiplier is applied to the stream’s computed line delta (including any carried - /// fractional remainder). - pub scroll_trackpad_accel_events: Option, - - /// Trackpad acceleration: maximum multiplier applied to trackpad-like streams. - /// - /// Set to 1 to effectively disable trackpad acceleration. - /// - /// See [`Tui::scroll_trackpad_accel_events`] for the exact multiplier formula. - pub scroll_trackpad_accel_max: Option, - - /// Select how TUI2 interprets mouse scroll input. - /// - /// - `auto` (default): infer wheel vs trackpad per scroll stream. - /// - `wheel`: always use wheel behavior (fixed lines per wheel notch). - /// - `trackpad`: always use trackpad behavior (fractional accumulation; wheel may feel slow). - #[serde(default)] - pub scroll_mode: ScrollInputMode, - - /// Auto-mode threshold: maximum time (ms) for the first tick-worth of events to arrive. - /// - /// In `scroll_mode = "auto"`, TUI2 starts a stream as trackpad-like (to avoid overshoot) and - /// promotes it to wheel-like if `scroll_events_per_tick` events arrive "quickly enough". This - /// threshold controls what "quickly enough" means. - /// - /// Most users should leave this unset; it is primarily for terminals that emit wheel ticks - /// batched over longer time spans. - pub scroll_wheel_tick_detect_max_ms: Option, - - /// Auto-mode fallback: maximum duration (ms) that a very small stream is still treated as wheel-like. - /// - /// This is only used when `scroll_events_per_tick` is effectively 1 (one event per wheel - /// notch). In that case, we cannot observe a "tick completion time", so TUI2 treats a - /// short-lived, small stream (<= 2 events) as wheel-like to preserve classic wheel behavior. - pub scroll_wheel_like_max_duration_ms: Option, - - /// Invert mouse scroll direction in TUI2. - /// - /// This flips the scroll sign after terminal detection. It is applied consistently to both - /// wheel and trackpad input. - #[serde(default)] - pub scroll_invert: bool, - /// Controls whether the TUI uses the terminal's alternate screen buffer. /// /// - `auto` (default): Disable alternate screen in Zellij, enable elsewhere. diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 6d1c40769e8..407e40e3d69 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -95,8 +95,6 @@ pub enum Feature { ShellSnapshot, /// Append additional AGENTS.md guidance to user instructions. ChildAgentsMd, - /// Experimental TUI v2 (viewport) implementation. - Tui2, /// Enforce UTF8 output in Powershell. PowershellUtf8, /// Compress request bodies (zstd) when sending streaming requests to codex-backend. @@ -441,12 +439,6 @@ pub const FEATURES: &[FeatureSpec] = &[ }, default_enabled: false, }, - FeatureSpec { - id: Feature::Tui2, - key: "tui2", - stage: Stage::Beta, - default_enabled: false, - }, FeatureSpec { id: Feature::Steer, key: "steer", diff --git a/codex-rs/tui/src/bottom_pane/AGENTS.md b/codex-rs/tui/src/bottom_pane/AGENTS.md index 32d2037c13b..b5328217db7 100644 --- a/codex-rs/tui/src/bottom_pane/AGENTS.md +++ b/codex-rs/tui/src/bottom_pane/AGENTS.md @@ -6,8 +6,7 @@ When changing the paste-burst or chat-composer state machines in this folder, ke readable, top-down explanation of the current behavior. - Update the narrative doc `docs/tui-chat-composer.md` whenever behavior/assumptions change (Enter handling, retro-capture, flush/clear rules, `disable_paste_burst`, non-ASCII/IME handling). -- Keep `codex-rs/tui` and `codex-rs/tui2` implementations/docstrings aligned unless the divergence - is intentional and documented. +- Keep implementations/docstrings aligned unless a divergence is intentional and documented. Practical check: diff --git a/codex-rs/tui2/BUILD.bazel b/codex-rs/tui2/BUILD.bazel deleted file mode 100644 index 70c977b6a3a..00000000000 --- a/codex-rs/tui2/BUILD.bazel +++ /dev/null @@ -1,20 +0,0 @@ -load("//:defs.bzl", "codex_rust_crate") - -codex_rust_crate( - name = "tui2", - crate_name = "codex_tui2", - compile_data = glob( - include = ["**"], - exclude = [ - "**/* *", - "BUILD.bazel", - "Cargo.toml", - ], - allow_empty = True, - ), - test_data_extra = glob(["src/**/snapshots/**"]), - integration_compile_data_extra = ["src/test_backend.rs"], - extra_binaries = [ - "//codex-rs/cli:codex", - ], -) diff --git a/codex-rs/tui2/Cargo.toml b/codex-rs/tui2/Cargo.toml deleted file mode 100644 index 2abb5adb4bc..00000000000 --- a/codex-rs/tui2/Cargo.toml +++ /dev/null @@ -1,123 +0,0 @@ -[package] -name = "codex-tui2" -version.workspace = true -edition.workspace = true -license.workspace = true - -[[bin]] -name = "codex-tui2" -path = "src/main.rs" - -[lib] -name = "codex_tui2" -path = "src/lib.rs" - -[features] -# Enable vt100-based tests (emulator) when running with `--features vt100-tests`. -vt100-tests = [] -# Gate verbose debug logging inside the TUI implementation. -debug-logs = [] - -[lints] -workspace = true - -[dependencies] -anyhow = { workspace = true } -async-stream = { workspace = true } -base64 = { workspace = true } -chrono = { workspace = true, features = ["serde"] } -clap = { workspace = true, features = ["derive"] } -codex-ansi-escape = { workspace = true } -codex-app-server-protocol = { workspace = true } -codex-arg0 = { workspace = true } -codex-backend-client = { workspace = true } -codex-common = { workspace = true, features = [ - "cli", - "elapsed", - "sandbox_summary", -] } -codex-core = { workspace = true } -codex-feedback = { workspace = true } -codex-file-search = { workspace = true } -codex-login = { workspace = true } -codex-otel = { workspace = true } -codex-protocol = { workspace = true } -codex-utils-absolute-path = { workspace = true } -codex-tui = { workspace = true } -color-eyre = { workspace = true } -crossterm = { workspace = true, features = ["bracketed-paste", "event-stream"] } -derive_more = { workspace = true, features = ["is_variant"] } -diffy = { workspace = true } -dirs = { workspace = true } -dunce = { workspace = true } -image = { workspace = true, features = ["jpeg", "png"] } -itertools = { workspace = true } -lazy_static = { workspace = true } -mcp-types = { workspace = true } -pathdiff = { workspace = true } -pulldown-cmark = { workspace = true } -rand = { workspace = true } -ratatui = { workspace = true, features = [ - "scrolling-regions", - "unstable-backend-writer", - "unstable-rendered-line-info", - "unstable-widget-ref", -] } -ratatui-core = { workspace = true } -ratatui-macros = { workspace = true } -regex-lite = { workspace = true } -reqwest = { version = "0.12", features = ["json"] } -serde = { workspace = true, features = ["derive"] } -serde_json = { workspace = true, features = ["preserve_order"] } -shlex = { workspace = true } -strum = { workspace = true } -strum_macros = { workspace = true } -supports-color = { workspace = true } -tempfile = { workspace = true } -textwrap = { workspace = true } -tui-scrollbar = { workspace = true } -tokio = { workspace = true, features = [ - "io-std", - "macros", - "process", - "rt-multi-thread", - "signal", - "test-util", - "time", -] } -tokio-stream = { workspace = true } -toml = { workspace = true } -tracing = { workspace = true, features = ["log"] } -tracing-appender = { workspace = true } -tracing-subscriber = { workspace = true, features = ["env-filter"] } -tree-sitter-bash = { workspace = true } -tree-sitter-highlight = { workspace = true } -unicode-segmentation = { workspace = true } -unicode-width = { workspace = true } -url = { workspace = true } - -codex-windows-sandbox = { workspace = true } -tokio-util = { workspace = true, features = ["time"] } - -[target.'cfg(unix)'.dependencies] -libc = { workspace = true } - -# Clipboard support via `arboard` is not available on Android/Termux. -# Only include it for non-Android targets so the crate builds on Android. -[target.'cfg(not(target_os = "android"))'.dependencies] -arboard = { workspace = true } - - -[dev-dependencies] -assert_matches = { workspace = true } -codex-cli = { workspace = true } -codex-core = { workspace = true, features = ["test-support"] } -codex-utils-cargo-bin = { workspace = true } -codex-utils-pty = { workspace = true } -chrono = { workspace = true, features = ["serde"] } -insta = { workspace = true } -pretty_assertions = { workspace = true } -rand = { workspace = true } -serial_test = { workspace = true } -vt100 = { workspace = true } -uuid = { workspace = true } diff --git a/codex-rs/tui2/docs/scroll_input_model.md b/codex-rs/tui2/docs/scroll_input_model.md deleted file mode 100644 index f6eef6a6cb6..00000000000 --- a/codex-rs/tui2/docs/scroll_input_model.md +++ /dev/null @@ -1,610 +0,0 @@ -# TUI2 Scroll Input: Model and Implementation - -This is the single "scrolling doc of record" for TUI2. - -It describes what we implemented, why it works, and what we tried before this approach. -It also preserves the scroll-probe findings (see Appendix) that motivated the model. - -Code reference: `codex-rs/tui2/src/tui/scrolling/mouse.rs`. - -## Goals and constraints - -Goals: - -- Mouse wheel: scroll about **3 transcript lines per physical wheel tick** regardless of terminal - event density (classic feel). -- Trackpad: remain **higher fidelity**, meaning small movements can accumulate fractionally and - should not be forced into wheel behavior. -- Work across terminals where a single wheel tick may produce 1, 3, 9, or more raw events. - -Constraints: - -- Terminals typically encode both wheels and trackpads as the same "scroll up/down" mouse button - events without a magnitude. We cannot reliably observe device type directly. -- Timing alone is not a reliable discriminator (wheel and trackpad bursts overlap). - -## Current implementation (stream-based; data-driven) - -TUI2 uses a stream model: scroll events are grouped into short streams separated by silence. -Within a stream, we normalize by a per-terminal "events per tick" factor and then apply either -wheel-like (fixed lines per tick) or trackpad-like (fractional) semantics. - -### 1. Stream detection - -- A stream begins on the first scroll event. -- A stream ends when the gap since the last event exceeds `STREAM_GAP_MS` or when direction flips. -- Direction flips always close the current stream and start a new one, so we never blend "up" and - "down" into a single accumulator. - -This makes behavior stable across: - -- Dense bursts (Warp/Ghostty-style sub-ms intervals). -- Sparse bursts (single events separated by tens or hundreds of ms). -- Mixed wheel + trackpad input where direction changes quickly. - -### 2. Normalization: events-per-tick - -Different terminals emit different numbers of raw events per physical wheel notch. -We normalize by converting raw events into tick-equivalents: - -`tick_equivalents = raw_events / events_per_tick` - -Per-terminal defaults come from the probe logs (Appendix), and users can override them. - -Config key: `tui.scroll_events_per_tick`. - -### 3. Wheel vs trackpad behavior (and why it is heuristic) - -Because device type is not directly observable, the implementation provides a mode setting: - -- `tui.scroll_mode = "auto"` (default): infer wheel-like vs trackpad-like behavior per stream. -- `tui.scroll_mode = "wheel"`: always treat streams as wheel-like. -- `tui.scroll_mode = "trackpad"`: always treat streams as trackpad-like. - -In auto mode: - -- Streams start trackpad-like (safer: avoids overshoot when we guess wrong). -- Streams promote to wheel-like when the first tick-worth of events arrives quickly. -- For 1-event-per-tick terminals, "first tick completion time" is not observable, so there is a - conservative end-of-stream fallback for very small bursts. - -This design assumes that auto classification is a best-effort heuristic and must be overridable. - -### 4. Applying scroll: wheel-like streams - -Wheel-like streams target the "classic feel" requirement. - -- Each raw event contributes `tui.scroll_wheel_lines / events_per_tick` lines. -- Deltas flush immediately (not cadence-gated) so wheels feel snappy even on dense streams. -- Wheel-like streams apply a minimum +/- 1 line when events were received but rounding would yield 0. - -Defaults: - -- `tui.scroll_wheel_lines = 3` - -### 5. Applying scroll: trackpad-like streams - -Trackpad-like streams are designed for fidelity first. - -- Each raw event contributes `tui.scroll_trackpad_lines / trackpad_events_per_tick` lines. -- Fractional remainder is carried across streams, so tiny gestures accumulate instead of being lost. -- Trackpad deltas are cadence-gated to ~60 Hz (`REDRAW_CADENCE_MS`) to avoid redraw floods and to - reduce "stop lag" / overshoot. -- Trackpad streams intentionally do not apply a minimum +/- 1 line at stream end; if a gesture is - small enough to round to 0, it should feel like "no movement", not a forced jump. - -Dense wheel terminals (e.g. Ghostty/Warp) can emit trackpad streams with high event density. -Using a wheel-derived `events_per_tick = 9` for trackpad would make trackpads feel slow, so we use -a capped divisor for trackpad normalization: - -- `trackpad_events_per_tick = min(events_per_tick, 3)` - -Additionally, to keep small gestures precise while making large/fast swipes cover more content, -trackpad-like streams apply bounded acceleration based on event count: - -- `tui.scroll_trackpad_accel_events`: how many events correspond to +1x multiplier. -- `tui.scroll_trackpad_accel_max`: maximum multiplier. - -### 6. Guard rails and axis handling - -- Horizontal scroll events are ignored for vertical scrolling. -- Streams clamp event counts and accumulated line deltas to avoid floods. - -## Terminal defaults and per-terminal tuning - -Defaults are keyed by `TerminalName` (terminal family), not exact version. -Probe data is version-specific, so defaults should be revalidated as more logs arrive. - -Events-per-tick defaults derived from `wheel_single` medians: - -- AppleTerminal: 3 -- WarpTerminal: 9 -- WezTerm: 1 -- Alacritty: 3 -- Ghostty: 3 -- Iterm2: 1 -- VsCode: 1 -- Kitty: 3 -- Unknown: 3 - -Note: probe logs measured Ghostty at ~9 events per tick, but we default to 3 because an upstream -Ghostty change is expected to reduce wheel event density. Users can override with -`tui.scroll_events_per_tick`. - -Auto-mode wheel promotion thresholds can also be tuned per terminal if needed (see config below). - -## Configuration knobs (TUI2) - -These are user-facing knobs in `config.toml` under `[tui]`: - -In this repo, "tick" always refers to a physical mouse wheel notch. Trackpads do not have ticks, so -trackpad settings are expressed in terms of "tick-equivalents" (raw events normalized to a common -scale). - -The core normalization formulas are: - -- Wheel-like streams: - - `lines_per_event = scroll_wheel_lines / scroll_events_per_tick` -- Trackpad-like streams: - - `lines_per_event = scroll_trackpad_lines / min(scroll_events_per_tick, 3)` - - (plus bounded acceleration from `scroll_trackpad_accel_*` and fractional carry across streams) - -Keys: - -- `scroll_events_per_tick` (number): - - Raw vertical scroll events per physical wheel notch in your terminal (normalization input). - - Affects wheel-like scroll speed and auto-mode wheel promotion timing. - - Trackpad-like mode uses `min(..., 3)` as the divisor so dense wheel ticks (e.g. 9 events per - notch) do not make trackpads feel artificially slow. -- `scroll_wheel_lines` (number): - - Lines per physical wheel notch (default 3). - - Change this if you want "classic" wheel scrolling to be more/less aggressive globally. -- `scroll_trackpad_lines` (number): - - Baseline trackpad sensitivity in trackpad-like mode (default 1). - - Change this if your trackpad feels consistently too slow/fast for small motions. -- `scroll_trackpad_accel_events` (number): - - Trackpad acceleration tuning (default 30). Smaller values accelerate earlier. - - Trackpad-like streams compute a multiplier: - - `multiplier = clamp(1 + abs(events) / scroll_trackpad_accel_events, 1..scroll_trackpad_accel_max)` - - The multiplier is applied to the trackpad stream’s computed line delta (including any carried - fractional remainder). -- `scroll_trackpad_accel_max` (number): - - Trackpad acceleration cap (default 3). Set to 1 to effectively disable acceleration. -- `scroll_mode` (`auto` | `wheel` | `trackpad`): - - `auto` (default): infer wheel-like vs trackpad-like per stream. - - `wheel`: always wheel-like (good for wheel-only setups; trackpads will feel jumpy). - - `trackpad`: always trackpad-like (good if auto misclassifies; wheels may feel slow). -- `scroll_wheel_tick_detect_max_ms` (number): - - Auto-mode promotion threshold: how quickly the first tick-worth of events must arrive to - consider the stream wheel-like. - - If wheel feels slow in a dense-wheel terminal, increasing this is usually better than changing - `scroll_events_per_tick`. -- `scroll_wheel_like_max_duration_ms` (number): - - Auto-mode fallback for 1-event-per-tick terminals (WezTerm/iTerm/VS Code). - - If wheel feels like trackpad (too slow) in those terminals, increasing this can help. -- `scroll_invert` (bool): - - Invert direction after terminal detection; applies consistently to wheel and trackpad. - -## Previous approaches tried (and why they were replaced) - -1. Cadence-based inference (rolling inter-event thresholds) - -- Approach: infer wheel vs trackpad using inter-event timing thresholds (burst vs frame cadence vs slow), - with terminal-specific tuning. -- Problem: terminals differ more in event density and batching than in timing; timing overlaps heavily - between wheel and trackpad. Small threshold changes had outsized, terminal-specific effects. - -2. Pure event-count or pure duration classification - -- Approach: classify wheel-like vs trackpad-like by event count <= N or duration <= M. -- Problem: burst length overlaps heavily across devices/terminals; duration is more separable but still - not strong enough to be authoritative. - -3. Why streams + normalization won - -- Streams give a stable unit ("what did the user do in one gesture?") that we can bound and reason about. -- Normalization directly addresses the main cross-terminal source of variation: raw event density. -- Classification remains heuristic, but is isolated and configurable. - -## Appendix A: Follow-up analysis (latest log per terminal; 2025-12-20) - -This section is derived from a "latest log per terminal" subset analysis. The exact event count is -not significant; it is included only as a note about which subset was used. - -Key takeaways: - -- Burst length overlaps heavily between wheel and trackpad. Simple "event count <= N" classifiers perform poorly. -- Burst span (duration) is more separable: wheel bursts typically complete in < ~180-200 ms, while trackpad - bursts are often hundreds of milliseconds. -- Conclusion: explicit wheel vs trackpad classification is inherently weak from these events; prefer a - stream model, plus a small heuristic and a config override (`tui.scroll_mode`) for edge cases. - -Data notes (latest per terminal label): - -- Logs used (one per terminal, by filename timestamp): - - mouse_scroll_log_Apple_Terminal_2025-12-19T19-53-54Z.jsonl - - mouse_scroll_log_WarpTerminal_2025-12-19T19-59-38Z.jsonl - - mouse_scroll_log_WezTerm_2025-12-19T20-00-36Z.jsonl - - mouse_scroll_log_alacritty_2025-12-19T19-56-45Z.jsonl - - mouse_scroll_log_ghostty_2025-12-19T19-52-44Z.jsonl - - mouse_scroll_log_iTerm_app_2025-12-19T19-55-08Z.jsonl - - mouse_scroll_log_vscode_2025-12-19T19-51-20Z.jsonl - - mouse_scroll_log_xterm-kitty_2025-12-19T19-58-19Z.jsonl - -Per-terminal burst separability (wheel vs trackpad), summarized as median and p90: - -- Apple Terminal: - - Wheel: length median 9.5 (p90 49), span median 94 ms (p90 136) - - Trackpad: length median 13.5 (p90 104), span median 238 ms (p90 616) -- Warp: - - Wheel: length median 43 (p90 169), span median 88 ms (p90 178) - - Trackpad: length median 60 (p90 82), span median 358 ms (p90 721) -- WezTerm: - - Wheel: length median 4 (p90 10), span median 91 ms (p90 156) - - Trackpad: length median 10.5 (p90 36), span median 270 ms (p90 348) -- alacritty: - - Wheel: length median 14 (p90 63), span median 109 ms (p90 158) - - Trackpad: length median 12.5 (p90 63), span median 372 ms (p90 883) -- ghostty: - - Wheel: length median 32.5 (p90 163), span median 99 ms (p90 157) - - Trackpad: length median 14.5 (p90 60), span median 366 ms (p90 719) -- iTerm: - - Wheel: length median 4 (p90 9), span median 91 ms (p90 230) - - Trackpad: length median 9 (p90 36), span median 223 ms (p90 540) -- VS Code: - - Wheel: length median 3 (p90 9), span median 94 ms (p90 120) - - Trackpad: length median 3 (p90 12), span median 192 ms (p90 468) -- Kitty: - - Wheel: length median 15.5 (p90 59), span median 87 ms (p90 233) - - Trackpad: length median 15.5 (p90 68), span median 292 ms (p90 563) - -Wheel_single medians (events per tick) in the latest logs: - -- Apple: 3 -- Warp: 9 -- WezTerm: 1 -- alacritty: 3 -- ghostty: 9 (measured); TUI2 defaults use 3 because an upstream Ghostty change is expected to - reduce wheel event density. If your Ghostty build still emits ~9 events per wheel tick, set - `tui.scroll_events_per_tick = 9`. -- iTerm: 1 -- VS Code: 1 -- Kitty: 3 - -## Appendix B: Scroll probe findings (authoritative; preserved verbatim) - -The remainder of this document is preserved from the original scroll-probe spec. -It is intentionally not rewritten so the data and rationale remain auditable. - -Note: the original text uses "events per line" terminology; the implementation treats this as an -events-per-wheel-tick normalization factor (see "Normalization: events-per-tick"). - -Note: the pseudocode in the preserved spec is not the exact current implementation; it is kept as -historical context for how the probe data originally mapped into an algorithm. The current -implementation is described in the sections above. - -## 1. TL;DR - -Analysis of 16 scroll-probe logs (13,734 events) across 8 terminals shows large per-terminal variation in how many raw events are emitted per physical wheel tick (1-9+ events). Timing alone does not distinguish wheel vs trackpad; event counts and burst duration are more reliable. The algorithm below treats scroll input as short streams separated by gaps, normalizes events into line deltas using a per-terminal events-per-line factor, coalesces redraws at 60 Hz, and applies a minimum 1-line delta for discrete bursts. This yields stable behavior across dense streams, sparse bursts, and terminals that emit horizontal events. - -## 2. Data overview - -- Logs analyzed: 16 -- Total events: 13,734 -- Terminals covered: - - Apple_Terminal 455.1 - - WarpTerminal v0.2025.12.17.17.stable_02 - - WezTerm 20240203-110809-5046fc22 - - alacritty - - ghostty 1.2.3 - - iTerm.app 3.6.6 - - vscode 1.107.1 - - xterm-kitty -- Scenarios captured: `wheel_single`, `wheel_small`, `wheel_long`, `trackpad_single`, `trackpad_slow`, `trackpad_fast` (directional up/down variants treated as distinct bursts). -- Legacy `wheel_scroll_*` logs are mapped to `wheel_small` in analysis. - -## 3. Cross-terminal comparison table - -| Terminal | Scenario | Median Dt (ms) | P95 Dt (ms) | Typical burst | Notes | -| --------------------------------------- | --------------- | -------------: | ----------: | ------------: | ----------- | -| Apple_Terminal 455.1 | wheel_single | 0.14 | 97.68 | 3 | -| Apple_Terminal 455.1 | wheel_small | 0.12 | 23.81 | 19 | -| Apple_Terminal 455.1 | wheel_long | 0.03 | 15.93 | 48 | -| Apple_Terminal 455.1 | trackpad_single | 92.35 | 213.15 | 2 | -| Apple_Terminal 455.1 | trackpad_slow | 11.30 | 75.46 | 14 | -| Apple_Terminal 455.1 | trackpad_fast | 0.13 | 8.92 | 96 | -| WarpTerminal v0.2025.12.17.17.stable_02 | wheel_single | 0.07 | 0.34 | 9 | -| WarpTerminal v0.2025.12.17.17.stable_02 | wheel_small | 0.05 | 5.04 | 65 | -| WarpTerminal v0.2025.12.17.17.stable_02 | wheel_long | 0.01 | 0.42 | 166 | -| WarpTerminal v0.2025.12.17.17.stable_02 | trackpad_single | 9.77 | 32.64 | 10 | -| WarpTerminal v0.2025.12.17.17.stable_02 | trackpad_slow | 7.93 | 16.44 | 74 | -| WarpTerminal v0.2025.12.17.17.stable_02 | trackpad_fast | 5.40 | 10.04 | 74 | -| WezTerm 20240203-110809-5046fc22 | wheel_single | 416.07 | 719.64 | 1 | -| WezTerm 20240203-110809-5046fc22 | wheel_small | 19.41 | 50.19 | 6 | -| WezTerm 20240203-110809-5046fc22 | wheel_long | 13.19 | 29.96 | 10 | -| WezTerm 20240203-110809-5046fc22 | trackpad_single | 237.56 | 237.56 | 1 | -| WezTerm 20240203-110809-5046fc22 | trackpad_slow | 23.54 | 76.10 | 10 | 12.5% horiz | -| WezTerm 20240203-110809-5046fc22 | trackpad_fast | 7.10 | 24.86 | 32 | 12.6% horiz | -| alacritty | wheel_single | 0.09 | 0.33 | 3 | -| alacritty | wheel_small | 0.11 | 37.24 | 24 | -| alacritty | wheel_long | 0.01 | 15.96 | 56 | -| alacritty | trackpad_single | n/a | n/a | 1 | -| alacritty | trackpad_slow | 41.90 | 97.36 | 11 | -| alacritty | trackpad_fast | 3.07 | 25.13 | 62 | -| ghostty 1.2.3 | wheel_single | 0.05 | 0.20 | 9 | -| ghostty 1.2.3 | wheel_small | 0.05 | 7.18 | 52 | -| ghostty 1.2.3 | wheel_long | 0.02 | 1.16 | 146 | -| ghostty 1.2.3 | trackpad_single | 61.28 | 124.28 | 3 | 23.5% horiz | -| ghostty 1.2.3 | trackpad_slow | 23.10 | 76.30 | 14 | 34.7% horiz | -| ghostty 1.2.3 | trackpad_fast | 3.84 | 37.72 | 47 | 23.4% horiz | -| iTerm.app 3.6.6 | wheel_single | 74.96 | 80.61 | 1 | -| iTerm.app 3.6.6 | wheel_small | 20.79 | 84.83 | 6 | -| iTerm.app 3.6.6 | wheel_long | 16.70 | 50.91 | 9 | -| iTerm.app 3.6.6 | trackpad_single | n/a | n/a | 1 | -| iTerm.app 3.6.6 | trackpad_slow | 17.25 | 94.05 | 9 | -| iTerm.app 3.6.6 | trackpad_fast | 7.12 | 24.54 | 33 | -| vscode 1.107.1 | wheel_single | 58.01 | 58.01 | 1 | -| vscode 1.107.1 | wheel_small | 16.76 | 66.79 | 5 | -| vscode 1.107.1 | wheel_long | 9.86 | 32.12 | 8 | -| vscode 1.107.1 | trackpad_single | n/a | n/a | 1 | -| vscode 1.107.1 | trackpad_slow | 164.19 | 266.90 | 3 | -| vscode 1.107.1 | trackpad_fast | 16.78 | 61.05 | 11 | -| xterm-kitty | wheel_single | 0.16 | 51.74 | 3 | -| xterm-kitty | wheel_small | 0.10 | 24.12 | 26 | -| xterm-kitty | wheel_long | 0.01 | 16.10 | 56 | -| xterm-kitty | trackpad_single | 155.65 | 289.87 | 1 | 12.5% horiz | -| xterm-kitty | trackpad_slow | 16.89 | 67.04 | 16 | 30.4% horiz | -| xterm-kitty | trackpad_fast | 0.23 | 16.37 | 78 | 20.6% horiz | - -## 4. Key findings - -- Raw wheel ticks vary by terminal: median events per tick are 1 (WezTerm/iTerm/vscode), 3 (Apple/alacritty/kitty), and 9 (Warp/ghostty). -- Trackpad bursts are longer than wheel ticks but overlap in timing; inter-event timing alone does not distinguish device type. -- Continuous streams have short gaps: overall inter-event p99 is 70.67 ms; trackpad_slow p95 is 66.98 ms. -- Horizontal events appear only in trackpad scenarios and only in WezTerm/ghostty/kitty; ignore horizontal events for vertical scrolling. -- Burst duration is a reliable discrete/continuous signal: - - wheel_single median 0.15 ms (p95 80.61 ms) - - trackpad_single median 0 ms (p95 237.56 ms) - - wheel_small median 96.88 ms (p95 182.90 ms) - - trackpad_slow median 320.69 ms (p95 812.10 ms) - -## 5. Scrolling model (authoritative) - -**Stream detection.** Treat scroll input as short streams separated by silence. A stream begins on the first scroll event and ends when the gap since the last event exceeds `STREAM_GAP_MS` or the direction flips. Direction flip immediately closes the current stream and starts a new one. - -**Normalization.** Convert raw events into line deltas using a per-terminal `EVENTS_PER_LINE` factor derived from the terminal's median `wheel_single` burst length. If no terminal override matches, use the global default (`3`). - -**Discrete vs continuous.** Classify the stream after it ends: - -- If `event_count <= DISCRETE_MAX_EVENTS` **and** `duration_ms <= DISCRETE_MAX_DURATION_MS`, treat as discrete. -- Otherwise treat as continuous. - -**Discrete streams.** Apply the accumulated line delta immediately. If the stream's accumulated lines rounds to 0 but events were received, apply a minimum +/-1 line (respecting direction). - -**Continuous streams.** Accumulate fractional lines and coalesce redraws to `REDRAW_CADENCE_MS`. Flush any remaining fractional lines on stream end (with the same +/-1 minimum if the stream had events but rounded to 0). - -**Direction.** Always use the raw event direction. Provide a separate user-level invert option if needed; do not infer inversion from timing. - -**Horizontal events.** Ignore horizontal events in vertical scroll logic. - -## 6. Concrete constants (data-derived) - -```text -STREAM_GAP_MS = 80 -DISCRETE_MAX_EVENTS = 10 -DISCRETE_MAX_DURATION_MS = 250 -REDRAW_CADENCE_MS = 16 -DEFAULT_EVENTS_PER_LINE = 3 -MAX_EVENTS_PER_STREAM = 256 -MAX_ACCUMULATED_LINES = 256 -MIN_LINES_PER_DISCRETE_STREAM = 1 -DEFAULT_WHEEL_LINES_PER_TICK = 3 -``` - -Why these values: - -- `STREAM_GAP_MS=80`: overall p99 inter-event gap is 70.67 ms; trackpad_slow p95 is 66.98 ms. 80 ms ends streams without splitting most continuous input. -- `DISCRETE_MAX_EVENTS=10`: wheel_single p95 burst = 9; trackpad_single p95 burst = 10. -- `DISCRETE_MAX_DURATION_MS=250`: trackpad_single p95 duration = 237.56 ms. -- `REDRAW_CADENCE_MS=16`: coalesces dense streams to ~60 Hz; trackpad_fast p95 Dt = 19.83 ms. -- `DEFAULT_EVENTS_PER_LINE=3`: global median wheel_single burst length. -- `MAX_EVENTS_PER_STREAM=256` and `MAX_ACCUMULATED_LINES=256`: highest observed burst is 206; cap to avoid floods. -- `DEFAULT_WHEEL_LINES_PER_TICK=3`: restores classic wheel speed; this is a UX choice rather than a data-derived constant. - -## 7. Pseudocode (Rust-oriented) - -```rust -// This is intentionally a simplified sketch of the current implementation. -// For the authoritative behavior, see `codex-rs/tui2/src/tui/scrolling/mouse.rs`. - -enum StreamKind { - Unknown, - Wheel, - Trackpad, -} - -struct Stream { - start: Instant, - last: Instant, - dir: i32, - event_count: usize, - accumulated_events: i32, - applied_lines: i32, - kind: StreamKind, - just_promoted: bool, -} - -struct State { - stream: Option, - carry_lines: f32, - last_redraw_at: Instant, - cfg: Config, -} - -struct Config { - events_per_tick: u16, - wheel_lines_per_tick: u16, - trackpad_lines_per_tick: u16, - trackpad_accel_events: u16, - trackpad_accel_max: u16, - wheel_tick_detect_max: Duration, -} - -fn on_scroll_event(dir: i32, now: Instant, st: &mut State) -> i32 { - // Close stream on idle gap or direction flip. - if let Some(stream) = st.stream.as_ref() { - let gap = now.duration_since(stream.last); - if gap > STREAM_GAP || stream.dir != dir { - finalize_stream(now, st); - st.stream = None; - } - } - - let stream = st.stream.get_or_insert_with(|| Stream { - start: now, - last: now, - dir, - event_count: 0, - accumulated_events: 0, - applied_lines: 0, - kind: StreamKind::Unknown, - just_promoted: false, - }); - - stream.last = now; - stream.dir = dir; - stream.event_count = (stream.event_count + 1).min(MAX_EVENTS_PER_STREAM); - stream.accumulated_events = - (stream.accumulated_events + dir).clamp(-(MAX_EVENTS_PER_STREAM as i32), MAX_EVENTS_PER_STREAM as i32); - - // Auto-mode promotion: promote to wheel-like when the first tick-worth of events arrives quickly. - if matches!(stream.kind, StreamKind::Unknown) { - let ept = st.cfg.events_per_tick.max(1) as usize; - if ept >= 2 && stream.event_count >= ept && now.duration_since(stream.start) <= st.cfg.wheel_tick_detect_max { - stream.kind = StreamKind::Wheel; - stream.just_promoted = true; - } - } - - flush_lines(now, st) -} - -fn on_tick(now: Instant, st: &mut State) -> i32 { - if let Some(stream) = st.stream.as_ref() { - let gap = now.duration_since(stream.last); - if gap > STREAM_GAP { - return finalize_stream(now, st); - } - } - flush_lines(now, st) -} - -fn finalize_stream(now: Instant, st: &mut State) -> i32 { - // In auto mode, any stream that isn't wheel-like by promotion stays trackpad-like. - if let Some(stream) = st.stream.as_mut() { - if matches!(stream.kind, StreamKind::Unknown) { - stream.kind = StreamKind::Trackpad; - } - } - - let lines = flush_lines(now, st); - - // Carry fractional remainder across streams for trackpad-like input. - if let Some(stream) = st.stream.as_ref() { - if matches!(stream.kind, StreamKind::Trackpad) { - st.carry_lines = desired_lines_f32(st, stream) - stream.applied_lines as f32; - } else { - st.carry_lines = 0.0; - } - } - - lines -} - -fn flush_lines(now: Instant, st: &mut State) -> i32 { - let Some(stream) = st.stream.as_mut() else { return 0; }; - - let wheel_like = matches!(stream.kind, StreamKind::Wheel); - let cadence_elapsed = now.duration_since(st.last_redraw_at) >= REDRAW_CADENCE; - let should_flush = wheel_like || cadence_elapsed || stream.just_promoted; - if !should_flush { - return 0; - } - - let desired_total = desired_lines_f32(st, stream); - let mut desired_lines = desired_total.trunc() as i32; - - // Wheel guardrail: ensure we never produce a "dead tick" for non-zero input. - if wheel_like && desired_lines == 0 && stream.accumulated_events != 0 { - desired_lines = stream.accumulated_events.signum() * MIN_LINES_PER_DISCRETE_STREAM; - } - - let mut delta = desired_lines - stream.applied_lines; - if delta == 0 { - return 0; - } - - delta = delta.clamp(-MAX_ACCUMULATED_LINES, MAX_ACCUMULATED_LINES); - stream.applied_lines += delta; - stream.just_promoted = false; - st.last_redraw_at = now; - delta -} - -fn desired_lines_f32(st: &State, stream: &Stream) -> f32 { - let wheel_like = matches!(stream.kind, StreamKind::Wheel); - - let events_per_tick = if wheel_like { - st.cfg.events_per_tick.max(1) as f32 - } else { - // Trackpad divisor is capped so dense wheel terminals don't feel slow for trackpads. - st.cfg.events_per_tick.clamp(1, DEFAULT_EVENTS_PER_LINE).max(1) as f32 - }; - - let lines_per_tick = if wheel_like { - st.cfg.wheel_lines_per_tick.max(1) as f32 - } else { - st.cfg.trackpad_lines_per_tick.max(1) as f32 - }; - - let mut total = (stream.accumulated_events as f32 * (lines_per_tick / events_per_tick)) - .clamp(-(MAX_ACCUMULATED_LINES as f32), MAX_ACCUMULATED_LINES as f32); - - if !wheel_like { - total = (total + st.carry_lines).clamp(-(MAX_ACCUMULATED_LINES as f32), MAX_ACCUMULATED_LINES as f32); - - // Bounded acceleration for large swipes (keep small swipes precise). - let event_count = stream.accumulated_events.abs() as f32; - let accel = (1.0 + (event_count / st.cfg.trackpad_accel_events.max(1) as f32)) - .clamp(1.0, st.cfg.trackpad_accel_max.max(1) as f32); - total = (total * accel).clamp(-(MAX_ACCUMULATED_LINES as f32), MAX_ACCUMULATED_LINES as f32); - } - - total -} -``` - -## 8. Terminal-specific adjustments (minimal) - -Use per-terminal `EVENTS_PER_LINE` overrides derived from median `wheel_single` bursts: - -```text -Apple_Terminal 455.1 = 3 -WarpTerminal v0.2025.12.17.17.stable_02 = 9 -WezTerm 20240203-110809-5046fc22 = 1 -alacritty = 3 -ghostty 1.2.3 = 9 -iTerm.app 3.6.6 = 1 -vscode 1.107.1 = 1 -xterm-kitty = 3 -``` - -If terminal is not matched, use `DEFAULT_EVENTS_PER_LINE = 3`. - -## 9. Known weird cases and guardrails - -- Extremely dense streams (sub-ms Dt) occur in Warp/ghostty/kitty; redraw coalescing is mandatory. -- Sparse bursts (hundreds of ms between events) occur in trackpad_single; do not merge them into long streams. -- Horizontal scroll events (12-35% of trackpad events in some terminals) must be ignored for vertical scrolling. -- Direction inversion is user-configurable in terminals; always use event direction and expose an application-level invert setting. -- Guard against floods: cap event counts and accumulated line deltas per stream. diff --git a/codex-rs/tui2/docs/streaming_wrapping_design.md b/codex-rs/tui2/docs/streaming_wrapping_design.md deleted file mode 100644 index f7af4ccccba..00000000000 --- a/codex-rs/tui2/docs/streaming_wrapping_design.md +++ /dev/null @@ -1,169 +0,0 @@ -# Streaming Wrapping Reflow (tui2) - -This document describes a correctness bug in `codex-rs/tui2` and the chosen fix: -while streaming assistant markdown, soft-wrap decisions were effectively persisted as hard line -breaks, so resizing the viewport could not reflow prose. - -## Goal - -- Resizing the viewport reflows transcript prose (including streaming assistant output). -- Width-derived breaks are always treated as *soft wraps* (not logical newlines). -- Copy/paste continues to treat soft wraps as joinable (via joiners), and hard breaks as newlines. - -Non-goals: - -- Reflowing terminal scrollback that has already been printed. -- Reflowing content that is intentionally treated as preformatted (e.g., code blocks, raw stdout). - -## Background: where reflow happens in tui2 - -TUI2 renders the transcript as a list of `HistoryCell`s: - -1. A cell stores width-agnostic content (string, diff, logical lines, etc.). -2. At draw time (and on resize), `transcript_render` asks each cell for lines at the *current* - width (ideally via `HistoryCell::transcript_lines_with_joiners(width)`). -3. `TranscriptViewCache` caches the wrapped visual lines keyed by width; a width change triggers a - rebuild. - -This only works if cells do *not* persist width-derived wrapping inside their stored state. - -## The bug: soft wraps became hard breaks during streaming - -Ratatui represents multi-line content as `Vec`. If we split a paragraph into multiple `Line`s -because the viewport is narrow, that split is indistinguishable from an explicit newline unless we -also carry metadata describing which breaks were “soft”. - -Streaming assistant output used to generate already-wrapped `Line`s and store them inside the -history cell. Later, when the viewport became wider, the transcript renderer could not “un-split” -those baked lines — they looked like hard breaks. - -## Chosen solution (A, F1): stream logical markdown lines; wrap in the cell at render-time - -User choice recap: - -- **A**: Keep append-only streaming (new history cell per commit tick), but make the streamed data - width-agnostic. -- **F1**: Make the agent message cell responsible for wrapping-to-width so transcript-level wrapping - can be a no-op for it. - -### Key idea: separate markdown parsing from wrapping - -We introduce a width-agnostic “logical markdown line” representation that preserves the metadata -needed to wrap correctly later: - -- `codex-rs/tui2/src/markdown_render.rs` - - `MarkdownLogicalLine { content, initial_indent, subsequent_indent, line_style, is_preformatted }` - - `render_markdown_logical_lines(input: &str) -> Vec` - -This keeps: - -- hard breaks (paragraph/list boundaries, explicit newlines), -- markdown indentation rules for wraps (list markers, nested lists, blockquotes), -- preformatted runs (code blocks) stable. - -### Updated streaming pipeline - -- `codex-rs/tui2/src/markdown_stream.rs` - - `MarkdownStreamCollector` is newline-gated (no change), but now commits - `Vec` instead of already-wrapped `Vec`. - - Width is removed from the collector; wrapping is not performed during streaming. - -- `codex-rs/tui2/src/streaming/controller.rs` - - Emits `AgentMessageCell::new_logical(...)` containing logical lines. - -- `codex-rs/tui2/src/history_cell.rs` - - `AgentMessageCell` stores `Vec`. - - `HistoryCell::transcript_lines_with_joiners(width)` wraps each logical line at the current - width using `word_wrap_line_with_joiners` and composes indents as: - - transcript gutter prefix (`• ` / ` `), plus - - markdown-provided initial/subsequent indents. - - Preformatted logical lines are rendered without wrapping. - -Result: on resize, the transcript cache rebuilds against the new width and the agent output reflows -correctly because the stored content contains no baked soft wraps. - -## Overlay deferral fix (D): defer cells, not rendered lines - -When an overlay (transcript/static) is active, TUI2 is in alt screen and the normal terminal buffer -is not visible. Historically, `tui2` attempted to queue “history to print” for the normal buffer by -deferring *rendered lines*, which baked the then-current width. - -User choice recap: - -- **D**: Store deferred *cells* and render them at overlay close time. - -Implementation: - -- `codex-rs/tui2/src/app.rs` - - `deferred_history_cells: Vec>` (replaces `deferred_history_lines`). - - `AppEvent::InsertHistoryCell` pushes cells into the deferral list when `overlay.is_some()`. - -- `codex-rs/tui2/src/app_backtrack.rs` - - `close_transcript_overlay` renders deferred cells at the *current* width when closing the - overlay, then queues the resulting lines for the normal terminal buffer. - -Note: as of today, `Tui::insert_history_lines` queues lines but `Tui::draw` does not flush them into -the terminal (see `codex-rs/tui2/src/tui.rs`). This section is therefore best read as “behavior we -want when/if scrollback printing is re-enabled”, not a guarantee that content is printed during the -main TUI loop. For the current intended behavior around printing, see -`codex-rs/tui2/docs/tui_viewport_and_history.md`. - -## Tests (G2) - -User choice recap: - -- **G2**: Add resize reflow tests + snapshot coverage. - -Added coverage: - -- `codex-rs/tui2/src/history_cell.rs` - - `agent_message_cell_reflows_streamed_prose_on_resize` - - `agent_message_cell_reflows_streamed_prose_vt100_snapshot` - -These assert that a streamed agent cell produces fewer visual lines at wider widths and provide -snapshots showing reflow for list items and blockquotes. - -## Audit: other `HistoryCell`s and width-baked paths - -This section answers “what else might behave like this?” up front. - -### History cells - -- `AgentMessageCell` (`codex-rs/tui2/src/history_cell.rs`): **was affected**; now stores logical - markdown lines and wraps at render time. -- `UserHistoryCell` (`codex-rs/tui2/src/history_cell.rs`): wraps at render time from stored `String` - using `word_wrap_lines_with_joiners` (reflowable). -- `ReasoningSummaryCell` (`codex-rs/tui2/src/history_cell.rs`): renders from stored `String` on each - call; it does call `append_markdown(..., Some(width))`, but that wrapping is recomputed per width - (reflowable). -- `PrefixedWrappedHistoryCell` (`codex-rs/tui2/src/history_cell.rs`): wraps at render time and - returns joiners (reflowable). -- `PlainHistoryCell` (`codex-rs/tui2/src/history_cell.rs`): stores `Vec` and returns it - unchanged (not reflowable by design; used for already-structured/preformatted output). - -Rule of thumb: any cell that stores already-wrapped `Vec` for prose is a candidate for the -same bug; cells that store source text or logical lines and compute wrapping inside -`display_lines(width)` are safe. - -### Width-baked output outside the transcript model - -Even with the streaming fix, some paths are inherently width-baked: - -- Printed transcript after exit (`codex-rs/tui2/src/app.rs`): `AppExitInfo.session_lines` is rendered - once using the final width and then printed; it cannot reflow afterward. -- Optional scrollback insertion helper (`codex-rs/tui2/src/insert_history.rs`): once ANSI is written - to the terminal, that output cannot be reflowed later. This helper is currently used for - deterministic ANSI emission (`write_spans`) and tests; it is not wired into the main TUI draw - loop. -- Static overlays (`codex-rs/tui2/src/pager_overlay.rs`): reflow depends on whether callers provided - width-agnostic input; pre-split `Vec` cannot be “un-split” within the overlay. - -## Deferred / follow-ups - -The fix above is sufficient to unblock correct reflow on resize. Remaining choices can be deferred: - -- Streaming granularity: one logical line can wrap into multiple visual lines, so “commit tick” - updates can appear in larger chunks than before. If this becomes a UX issue, we can add a render- - time “progressive reveal” layer without reintroducing width baking. -- Expand logical-line rendering to other markdown-ish cells if needed (e.g., unify `append_markdown` - usage), but only if we find a concrete reflow bug beyond `AgentMessageCell`. diff --git a/codex-rs/tui2/docs/tui_viewport_and_history.md b/codex-rs/tui2/docs/tui_viewport_and_history.md deleted file mode 100644 index 50d23ffa142..00000000000 --- a/codex-rs/tui2/docs/tui_viewport_and_history.md +++ /dev/null @@ -1,564 +0,0 @@ -# TUI2 Viewport, Transcript, and History – Design Notes - -This document describes the viewport and history model we are implementing in the new -`codex-rs/tui2` crate. It builds on lessons from the legacy TUI and explains why we moved away -from directly writing history into terminal scrollback. - -The target audience is Codex developers and curious contributors who want to understand or -critique how TUI2 owns its viewport, scrollback, and suspend behavior. - -Unless stated otherwise, references to “the TUI” in this document mean the TUI2 implementation; -when we mean the legacy TUI specifically, we call it out explicitly. - ---- - -## 1. Problem Overview - -Historically, the legacy TUI tried to “cooperate” with the terminal’s own scrollback: - -- The inline viewport sat somewhere above the bottom of the screen. -- When new history arrived, we tried to insert it directly into the terminal scrollback above the - viewport. -- On certain transitions (e.g. switching sessions, overlays), we cleared and re‑wrote portions of - the screen from scratch. - -This had several failure modes: - -- **Terminal‑dependent behavior.** - - - Different terminals handle scroll regions, clears, and resize semantics differently. - - What looked correct in one terminal could drop or duplicate content in another. - -- **Resizes and layout churn.** - - - The TUI reacts to resizes, focus changes, and overlay transitions. - - When the viewport moved or its size changed, our attempts to keep scrollback “aligned” with the - in‑memory history could go out of sync. - - In practice this meant: - - Some lines were lost or overwritten. - - Others were duplicated or appeared in unexpected places. - -- **“Clear and rewrite everything” didn’t save us.** - - We briefly tried a strategy of clearing large regions (or the full screen) and re‑rendering - history when the layout changed. - - This ran into two issues: - - Terminals treat full clears differently. For example, Terminal.app often leaves the cleared - screen as a “page” at the top of scrollback, some terminals interpret only a subset of the - ANSI clear/scrollback codes, and others (like iTerm2) gate “clear full scrollback” behind - explicit user consent. - - Replaying a long session is expensive and still subject to timing/race conditions with user - output (e.g. shell prompts) when we weren’t in alt screen. - -The net result: the legacy TUI could not reliably guarantee “the history you see on screen is complete, in -order, and appears exactly once” across terminals, resizes, suspend/resume, and overlay transitions. - ---- - -## 2. Goals - -The redesign is guided by a few explicit goals: - -1. **Codex, not the terminal, owns the viewport.** - - - The in‑memory transcript (a list of history entries) is the single source of truth for what’s - on screen. - - The TUI decides how to map that transcript into the current viewport; scrollback becomes an - output target, not an extra data structure we try to maintain. - -2. **History must be correct, ordered, and never silently dropped.** - - - Every logical history cell should either: - - Be visible in the TUI, or - - Have been printed into scrollback as part of a suspend/exit flow. - - We would rather (rarely) duplicate content than risk losing it. - -3. **Avoid unnecessary duplication.** - - - When emitting history to scrollback (on suspend or exit), print each logical cell’s content at - most once. - - Streaming cells are allowed to be “re‑seen” as they grow, but finished cells should not keep - reappearing. - -4. **Behave sensibly under resizes.** - - - TUI rendering should reflow to the current width on every frame. - - History printed to scrollback may have been wrapped at different widths over time; that is - acceptable, but it must not cause missing content or unbounded duplication. - -5. **Suspend/alt‑screen interaction is predictable.** - - `Ctrl+Z` should: - - Cleanly exit alt screen, if active. - - Print a consistent transcript prefix into normal scrollback. - - Resume with the TUI fully redrawn, without stale artifacts. - ---- - -## 3. New Viewport & Transcript Model - -### 3.1 Transcript as a logical sequence of cells - -At a high level, the TUI transcript is a list of “cells”, each representing one logical thing in -the conversation: - -- A user prompt (with padding and a distinct background). -- An agent response (which may arrive in multiple streaming chunks). -- System or info rows (session headers, migration banners, reasoning summaries, etc.). - -Each cell knows how to draw itself for a given width: how many lines it needs, what prefixes to -use, how to style its content. The transcript itself is purely logical: - -- It has no scrollback coordinates or terminal state baked into it. -- It can be re‑rendered for any viewport width. - -The TUI’s job is to take this logical sequence and decide how much of it fits into the current -viewport, and how it should be wrapped and styled on screen. - -### 3.2 Building viewport lines from the transcript - -To render the main transcript area above the composer, the TUI: - -1. Defines a “transcript region” as the full frame minus the height of the bottom input area. -2. Flattens all cells into a list of visual lines, remembering for each visual line which cell it - came from and which line within that cell it corresponds to. -3. Uses this flattened list plus a scroll position to decide which visual line should appear at the - top of the region. -4. Clears the transcript region and draws the visible slice of lines into it. -5. For user messages, paints the entire row background (including padding lines) so the user block - stands out even when it does not fill the whole width. -6. Applies selection styling and other overlays on top of the rendered lines. - -Scrolling (mouse wheel, PgUp/PgDn, Home/End) operates entirely in terms of these flattened lines -and the current scroll anchor. The terminal’s own scrollback is not part of this calculation; it -only ever sees fully rendered frames. - -### 3.3 Alternate screen, overlays, and redraw guarantees - -The TUI uses the terminal’s alternate screen for: - -- The main interactive chat session (so the viewport can cover the full terminal). -- Full‑screen overlays such as the transcript pager, diff view, model migration screen, and - onboarding. - -Conceptually: - -- Entering alt screen: - - - Switches the terminal into alt screen and expands the viewport to cover the full terminal. - - Clears that alt‑screen buffer. - -- Leaving alt screen: - - - Disables “alternate scroll” so mouse wheel events behave predictably. - - Returns to the normal screen. - -- On leaving overlays and on resuming from suspend, the TUI viewport is explicitly cleared and fully - redrawn: - - This prevents stale overlay content or shell output from lingering in the TUI area. - - The next frame reconstructs the UI entirely from the in‑memory transcript and other state, not - from whatever the terminal happened to remember. - -Alt screen is therefore treated as a temporary render target. The only authoritative copy of the UI -is the in‑memory state. - ---- - -## 4. Mouse, Selection, and Scrolling - -Mouse interaction is a first‑class part of the new design: - -- **Scrolling.** - - - Mouse wheel scrolls the transcript in fixed line increments. - - Keyboard shortcuts (PgUp/PgDn/Home/End) use the same scroll model, so the footer can show - consistent hints regardless of input device. - -- **Selection.** - - - A click‑and‑drag gesture defines a linear text selection in terms of the flattened transcript - lines (not raw buffer coordinates). - - Selection tracks the _content_ rather than a fixed screen row. When the transcript scrolls, the - selection moves along with the underlying lines instead of staying glued to a particular Y - position. - - The selection only covers the “transcript text” area; it intentionally skips the left gutter - that we use for bullets/prefixes. - -- **Copy.** - - When the user triggers copy, the TUI reconstructs the wrapped transcript lines using the same - flattening/wrapping rules as the visible view. - - It then reconstructs a high‑fidelity clipboard string from the selected logical lines: - - Preserves meaningful indentation (especially for code blocks). - - Treats soft-wrapped prose as a single logical line by joining wrap continuations instead of - inserting hard newlines. - - Emits Markdown source markers (e.g. backticks and fences) for copy/paste, even if the UI - chooses to render those constructs without showing the literal markers. - - Copy operates on the full selection range, even if the selection extends outside the current - viewport. - - The resulting text is sent to the system clipboard and a status footer indicates success or - failure. - -Because scrolling, selection, and copy all operate on the same flattened transcript representation, -they remain consistent even as the viewport resizes or the chat composer grows/shrinks. Owning our -own scrolling also means we must own mouse interactions end‑to‑end: if we left scrolling entirely -to the terminal, we could not reliably line up selections with transcript content or avoid -accidentally copying gutter/margin characters instead of just the conversation text. - -Scroll normalization details and the data behind it live in -`codex-rs/tui2/docs/scroll_input_model.md`. - ---- - -## 5. Printing History to Scrollback - -We still want the final session (and suspend points) to appear in the user’s normal scrollback, but -we no longer try to maintain scrollback in lock‑step with the TUI frame. Instead, we treat -scrollback as an **append‑only log** of logical transcript cells. - -In practice this means: - -- The TUI may print history both when you suspend (`Ctrl+Z`) and when you exit. -- Some users may prefer to only print on exit (for example to keep scrollback quieter during long - sessions). The current design anticipates gating suspend‑time printing behind a config toggle so - that this behavior can be made opt‑in or opt‑out without touching the core viewport logic, but - that switch has not been implemented yet. - -### 5.1 Cell‑based high‑water mark - -Internally, the TUI keeps a simple “high‑water mark” for history printing: - -- Think of this as “how many cells at the front of the transcript have already been sent to - scrollback.” -- It is just a counter over the logical transcript, not over wrapped lines. -- It moves forward only when we have actually printed more history. - -This means we never try to guess “how many terminal lines have already been printed”; we only -remember that “the first N logical entries are done.” - -### 5.2 Rendering new cells for scrollback - -When we need to print history (on suspend or exit), we: - -1. Take the suffix of the transcript that lies beyond the high‑water mark. -2. Render just that suffix into styled lines at the **current** terminal width. -3. Write those lines to stdout. -4. Advance the high‑water mark to include all cells we just printed. - -Older cells are never re‑rendered for scrollback; they remain in whatever wrapping they had when -they were first printed. This avoids the line‑count–based bugs we had before while still allowing -the on‑screen TUI to reflow freely. - -### 5.3 Suspend (`Ctrl+Z`) flow - -On suspend (typically `Ctrl+Z` on Unix): - -- Before yielding control back to the shell, the TUI: - - Leaves alt screen if it is active and restores normal terminal modes. - - Determines which transcript cells have not yet been printed and renders them for the current - width. - - Prints those new lines once into normal scrollback. - - Marks those cells as printed in the high‑water mark. - - Finally, sends the process to the background. - -On `fg`, the process resumes, re‑enters TUI modes, and redraws the viewport from the in‑memory -transcript. The history printed during suspend stays in scrollback and is not touched again. - -### 5.4 Exit flow - -When the TUI exits, we follow the same principle: - -- We compute the suffix of the transcript that has not yet been printed (taking into account any - prior suspends). -- We render just that suffix to styled lines at the current width. -- The outer `main` function leaves alt screen, restores the terminal, and prints those lines, plus a - blank line and token usage summary. - -If you never suspended, exit prints the entire transcript once. If you did suspend one or more -times, exit prints only the cells appended after the last suspend. In both cases, each logical -conversation entry reaches scrollback exactly once. - ---- - -## 6. Streaming, Width Changes, and Tradeoffs - -### 6.1 Streaming cells - -Streaming agent responses are represented as a sequence of history entries: - -- The first chunk produces a “first line” entry for the message. -- Subsequent chunks produce continuation entries that extend that message. - -From the history/scrollback perspective: - -- Each streaming chunk is just another entry in the logical transcript. -- The high‑water mark is a simple count of how many entries at the _front_ of the transcript have - already been printed. -- As new streaming chunks arrive, they are appended as new entries and will be included the next - time we print history on suspend or exit. - -We do **not** attempt to reprint or retroactively merge older chunks. In scrollback you will see the -streaming response as a series of discrete blocks, matching the internal history structure. - -Today, streaming rendering still “bakes in” some width at the time chunks are committed: line breaks -for the streaming path are computed using the width that was active at the time, and stored in the -intermediate representation. This is a known limitation and is called out in more detail in -`codex-rs/tui2/docs/streaming_wrapping_design.md`; a follow‑up change will make streaming behavior -match the rest of the transcript more closely (wrap only at display time, not at commit time). - -### 6.2 Width changes over time - -Because we now use a **cell‑level** high‑water mark instead of a visual line‑count, width changes -are handled gracefully: - -- On every suspend/exit, we render the not‑yet‑printed suffix of the transcript at the **current** - width and append those lines. -- Previously printed entries remain in scrollback with whatever wrapping they had at the time they - were printed. -- We no longer rely on “N lines printed before, therefore skip N lines of the newly wrapped - transcript,” which was the source of dropped and duplicated content when widths changed. - -This does mean scrollback can contain older cells wrapped for narrower or wider widths than the -final terminal size, but: - -- Each logical cell’s content appears exactly once. -- New cells are append‑only and never overwrite or implicitly “shrink” earlier content. -- The on‑screen TUI always reflows to the current width independently of scrollback. - -If we later choose to also re‑emit the “currently streaming” cell when printing on suspend (to make -sure the latest chunk of a long answer is always visible in scrollback), that would intentionally -duplicate a small number of lines at the boundary of that cell. The design assumes any such behavior -would be controlled by configuration (for example, by disabling suspend‑time printing entirely for -users who prefer only exit‑time output). - -### 6.3 Why not reflow scrollback? - -In theory we could try to reflow already‑printed content when widths change by: - -- Recomputing the entire transcript at the new width, and -- Printing diffs that “rewrite” old regions in scrollback. - -In practice, this runs into the same issues that motivated the redesign: - -- Terminals treat full clears and scroll regions differently. -- There is no portable way to “rewrite” arbitrary portions of scrollback above the visible buffer. -- Interleaving user output (e.g. shell prompts after suspend) makes it impossible to reliably - reconstruct the original scrollback structure. - -We therefore deliberately accept that scrollback is **append‑only** and not subject to reflow; -correctness is measured in terms of logical transcript content, not pixel‑perfect layout. - ---- - -## 7. Backtrack and Overlays (Context) - -While this document is focused on viewport and history, it’s worth mentioning a few related -behaviors that rely on the same model. - -### 7.1 Transcript overlay and backtrack - -The transcript overlay (pager) is a full‑screen view of the same logical transcript: - -- When opened, it takes a snapshot of the current transcript and renders it in an alt‑screen - overlay. -- Backtrack mode (`Esc` sequences) walks backwards through user messages in that snapshot and - highlights the candidate “edit from here” point. -- Confirming a backtrack request forks the conversation on the server and trims the in‑memory - transcript so that only history up to the chosen user message remains, then re‑renders that prefix - in the main view. - -The overlay is purely a different _view_ of the same transcript; it never infers anything from -scrollback. - ---- - -## 8. Summary of Tradeoffs - -**What we gain:** - -- The TUI has a clear, single source of truth for history (the in‑memory transcript). -- Viewport rendering is deterministic and independent of scrollback. -- Suspend and exit flows: - - Print each logical history cell exactly once. - - Are robust to terminal width changes. - - Interact cleanly with alt screen and raw‑mode toggling. -- Streaming, overlays, selection, and backtrack all share the same logical history model. -- Because cells are always re‑rendered live from the transcript, per‑cell interactions can become - richer over time. Instead of treating the transcript as “dead text”, we can make individual - entries interactive after they are rendered: expanding or contracting tool calls, diffs, or - reasoning summaries in place, jum…truncated… \*\*\* - ---- - -## 9. TUI2 Implementation Notes - -This section maps the design above onto the `codex-rs/tui2` crate so future viewport work has -concrete code pointers. - -### 9.1 Transcript state and layout - -The main app struct (`codex-rs/tui2/src/app.rs`) tracks the transcript and viewport state with: - -- `transcript_cells: Vec>` – the logical history. -- `transcript_scroll: TranscriptScroll` – whether the viewport is pinned to the bottom or - anchored at a specific cell/line pair. -- `transcript_selection: TranscriptSelection` – a selection expressed in content-relative - coordinates over the flattened, wrapped transcript (line index + column). -- `transcript_view_top` / `transcript_total_lines` – the current viewport’s top line index and - total number of wrapped lines for the inline transcript area. - -### 9.2 Rendering, wrapping, and selection - -`App::render_transcript_cells` defines the transcript region, builds flattened lines via -`App::build_transcript_lines`, wraps them with `word_wrap_lines_borrowed` from -`codex-rs/tui2/src/wrapping.rs`, and applies selection via `apply_transcript_selection` before -writing to the frame buffer. - -Streaming wrapping details live in `codex-rs/tui2/docs/streaming_wrapping_design.md`. - -### 9.3 Input, selection, and footer state - -Mouse handling lives in `App::handle_mouse_event`, keyboard scrolling in -`App::handle_key_event`, selection rendering in `App::apply_transcript_selection`, and copy in -`App::copy_transcript_selection` plus `codex-rs/tui2/src/transcript_selection.rs` and -`codex-rs/tui2/src/clipboard_copy.rs`. Scroll/selection UI state is forwarded through -`ChatWidget::set_transcript_ui_state`, -`BottomPane::set_transcript_ui_state`, and `ChatComposer::footer_props`, with footer text -assembled in `codex-rs/tui2/src/bottom_pane/footer.rs`. - -### 9.4 Exit transcript output - -`App::run` returns `session_lines` on `AppExitInfo` after flattening with -`App::build_transcript_lines` and converting to ANSI via `App::render_lines_to_ansi`. The CLI -prints those lines before the token usage and resume hints. - -## 10. Future Work and Open Questions - -### 10.1 Current status - -This design shipped behind the `tui2` feature flag (as a separate crate, duplicating the legacy -`tui` crate to enable rollout without breaking existing behavior). The following items from early -feedback are already implemented: - -- Bottom pane positioning is pegged high with an empty transcript and moves down as the transcript - fills (including on resume). -- Wheel-based transcript scrolling uses the stream-based normalization model derived from scroll - probe data (see `codex-rs/tui2/docs/scroll_input_model.md`). -- While a selection is active, streaming stops “follow latest output” so the selection remains - stable, and follow mode resumes after the selection is cleared. -- Copy operates on the full selection range (including offscreen lines), using the same wrapping as - on-screen rendering. -- Copy selection uses `Ctrl+Shift+C` (VS Code uses `Ctrl+Y` because `Ctrl+Shift+C` is unavailable in - the terminal) and shows an on-screen “copy” affordance near the selection. - -### 10.2 Roadmap (prioritized) - -This section captures a prioritized list of improvements we want to add to TUI2 based on early -feedback, with the goal of making scrolling/selection/copy feel as close to “native terminal” (and -Vim) behavior as we can while still owning the viewport. - -**P0 — must-have (usability/correctness):** - -- **Scrolling behavior.** Default to a classic multi-line wheel tick (3 lines, configurable) with - acceleration/velocity for faster navigation, and ensure we stop scrolling when the user stops - input (avoid redraw/event-loop backlog that makes scrolling feel “janky”). -- **Mouse event bounds.** Ignore mouse events outside the transcript region so clicks in the - composer/footer don’t start or mutate transcript selection state. -- **Copy fidelity.** Preserve meaningful indentation (especially code blocks), treat soft-wrapped - prose as a single logical line when copying, and copy markdown _source_ (including backticks and - heading markers) even if we render it differently. - -**P1 — should-have (UX polish and power user workflows):** - -- **Streaming wrapping polish.** Ensure all streaming paths use display-time wrapping only, and add - tests that cover resizing after streaming has started. -- **Selection semantics.** Define and implement selection behavior across multi-step output (and - whether step boundaries should be copy boundaries), while continuing to exclude the left gutter - from copied text. -- **Auto-scroll during drag.** While dragging a selection, auto-scroll when the cursor is at/near the - top or bottom of the transcript viewport to allow selecting beyond the visible window. -- **Width-aware selection.** Ensure selection highlighting and copy reconstruction handle wide glyphs - correctly (emoji, CJK), matching terminal display width rather than raw character count. -- **Multi-click selection.** Support double/triple/quad click selection (word/line/paragraph), - implemented on top of the transcript/viewport model rather than terminal buffer coordinates. -- **Find in transcript.** Add text search over the transcript (and consider integrating match - markers with any future scroll indicator work). -- **Cross-terminal behavior checks.** Validate copy/selection behavior across common terminals (incl. - terminal-provided “override selection” modes like holding Shift) and document the tradeoffs. - -**P2 — nice-to-have (polish, configuration, and interactivity):** - -- **Suspend printing.** Decide whether printing history on suspend is desirable at all (it is not - implemented yet). If we keep it, finalize the config shape/defaults, wire it through TUI startup, - and document it in the appropriate config docs. -- **Terminal integration.** Consider guiding (or optionally managing) terminal-emulator-specific - settings that affect TUI behavior (for example iTerm’s clipboard opt-in prompts or Ghostty - keybinding quirks), so the “works well out of the box” path is consistent across terminals. -- **Interactive cells (unlocked by transcript ownership).** Because transcript entries are structured - objects (not dead text in terminal scrollback), we can attach metadata to rendered regions and map - mouse/keys back to the underlying cell reliably across resizes and reflow. Examples: - - **Drill into a specific tool/command output.** Click (or press Enter) on a tool call / command - cell to open a focused overlay that shows the command, exit status, timing, and stdout/stderr as - separate sections, with dedicated “copy output” actions. This enables copying _just_ one command’s - output even when multiple commands are interleaved in a turn. - - **Copy an entire cell or entire turn.** Provide an action to copy a whole logical unit (one cell, - or “user prompt + assistant response”), without gutters and with well-defined boundaries. This is - hard to do with raw selection because step boundaries and padding aren’t reliably expressible in - terminal coordinates once the viewport moves or reflows. - - **Expand/collapse structured subregions with source-aware copy.** Tool calls, diffs, and - markdown can render in a compact form by default and expand in place. Copy actions can choose - between “copy rendered view” and “copy source” (e.g. raw markdown, raw JSON arguments, raw diff), - since we retain the original source alongside the rendered lines. - - **Cell-scoped actions.** Actions like “copy command”, “yank into composer”, “retry tool call”, or - “open related view” (diff/pager) can be offered per cell and behave deterministically, because the - UI can address cells by stable IDs rather than by fragile screen coordinates. -- **Additional affordances.** Consider an ephemeral scrollbar and/or a more explicit “selecting…” - status if footer hints aren’t sufficient. -- **UX capture.** Maintain short “golden path” clips showing scrolling (mouse + keys), selection and - copy, streaming under resize, and suspend/resume + exit printing. - -### 10.3 Open questions - -This section collects design questions that follow naturally from the current model and are worth -explicit discussion before we commit to further UI changes. - -- **“Scroll mode” vs “live follow” UI.** - - - We already distinguish “scrolled away from bottom” vs “following the latest output” in the - footer and scroll state. Do we need a more explicit “scroll mode vs live mode” affordance (e.g., - a dedicated indicator or toggle), or is the current behavior sufficient and adding more chrome - would be noise? - -- **Ephemeral scroll indicator.** - - - For long sessions, a more visible sense of “where am I?” could help. One option is a minimalist - scrollbar that appears while the user is actively scrolling and fades out when idle. A full - “mini‑map” is probably too heavy for a TUI given the limited vertical space, but we could - imagine adding simple markers along the scrollbar to show where prior prompts occurred, or - where text search matches are, without trying to render a full preview of the buffer. - -- **Selection affordances.** - - - Today, the primary hint that selection is active is the reversed text plus the on-screen “copy” - affordance (`Ctrl+Shift+C`) and the footer hint. Do we want an explicit “Selecting… (Esc to - cancel)” status while a drag is in progress, or would that be redundant/clutter for most users? - -- **Suspend banners in scrollback.** - - - When printing history on suspend, should we also emit a small banner such as - `--- codex suspended; history up to here ---` to make those boundaries obvious in scrollback? - This would slightly increase noise but could make multi‑suspend sessions easier to read. - -- **Configuring suspend printing behavior.** - - - The design already assumes that suspend‑time printing can be gated by config. Questions to - resolve: - - Should printing on suspend be on or off by default? - - Should we support multiple modes (e.g., “off”, “print all new cells”, “print streaming cell - tail only”) or keep it binary? - -- **Streaming duplication at the edges.** - - If we later choose to always re‑emit the “currently streaming” message when printing on suspend, - we would intentionally allow a small amount of duplication at the boundary of that message (for - example, its last line appearing twice across suspends). Is that acceptable if it improves the - readability of long streaming answers in scrollback, and should the ability to disable - suspend‑time printing be our escape hatch for users who care about exact de‑duplication?\*\*\* - ---- diff --git a/codex-rs/tui2/frames/blocks/frame_1.txt b/codex-rs/tui2/frames/blocks/frame_1.txt deleted file mode 100644 index 8c3263f5184..00000000000 --- a/codex-rs/tui2/frames/blocks/frame_1.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▒▓▒▓▒██▒▒██▒ - ▒▒█▓█▒█▓█▒▒░░▒▒ ▒ █▒ - █░█░███ ▒░ ░ █░ ░▒░░░█ - ▓█▒▒████▒ ▓█░▓░█ - ▒▒▓▓█▒░▒░▒▒ ▓░▒▒█ - ░█ █░ ░█▓▓░░█ █▓▒░░█ - █▒ ▓█ █▒░█▓ ░▒ ░▓░ - ░░▒░░ █▓▓░▓░█ ░░ - ░▒░█░ ▓░░▒▒░ ▓░██████▒██ ▒ ░ - ▒░▓█ ▒▓█░ ▓█ ░ ░▒▒▒▓▓███░▓█▓█░ - ▒▒▒ ▒ ▒▒█▓▓░ ░▒████ ▒█ ▓█▓▒▓ - █▒█ █ ░ ██▓█▒░ - ▒▒█░▒█▒ ▒▒▒█░▒█ - ▒██▒▒ ██▓▓▒▓▓▓▒██▒█░█ - ░█ █░░░▒▒▒█▒▓██ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_10.txt b/codex-rs/tui2/frames/blocks/frame_10.txt deleted file mode 100644 index a6fbbf1a4b8..00000000000 --- a/codex-rs/tui2/frames/blocks/frame_10.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▒████▒██▒ - ██░███▒░▓▒██ - ▒▒█░░▓░░▓░█▒██ - ░▒▒▓▒░▓▒▓▒███▒▒█ - ▓ ▓░░ ░▒ ██▓▒▓░▓ - ░░ █░█░▓▓▒ ░▒ ░ - ▒ ░█ █░░░░█ ░▓█ - ░░▒█▓█░░▓▒░▓▒░░ - ░▒ ▒▒░▓░░█▒█▓░░ - ░ █░▒█░▒▓▒█▒▒▒░█░ - █ ░░░░░ ▒█ ▒░░ - ▒░██▒██ ▒░ █▓▓ - ░█ ░░░░██▓█▓░▓░ - ▓░██▓░█▓▒ ▓▓█ - ██ ▒█▒▒█▓█ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_11.txt b/codex-rs/tui2/frames/blocks/frame_11.txt deleted file mode 100644 index 88e3dfa7c58..00000000000 --- a/codex-rs/tui2/frames/blocks/frame_11.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ███████▒ - ▓ ▓░░░▒▒█ - ▓ ▒▒░░▓▒█▓▒█ - ░▒▒░░▒▓█▒▒▓▓ - ▒ ▓▓▒░█▒█▓▒░░█ - ░█░░░█▒▓▓░▒▓░░ - ██ █░░░░░░▒░▒▒ - ░ ░░▓░░▒▓ ░ ░ - ▓ █░▓░░█▓█░▒░ - ██ ▒░▓▒█ ▓░▒░▒ - █░▓ ░░░░▒▓░▒▒░ - ▒▒▓▓░▒█▓██▓░░ - ▒ █░▒▒▒▒░▓ - ▒█ █░░█▒▓█░ - ▒▒ ███▒█░ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_12.txt b/codex-rs/tui2/frames/blocks/frame_12.txt deleted file mode 100644 index c6c0ef3e87d..00000000000 --- a/codex-rs/tui2/frames/blocks/frame_12.txt +++ /dev/null @@ -1,17 +0,0 @@ - - █████▓ - █▒░▒▓░█▒ - ░▓▒██ - ▓█░░░▒▒ ░ - ░ █░░░░▓▓░ - ░█▓▓█▒ ▒░ - ░ ░▓▒░░▒ - ░ ▓█▒░░ - ██ ░▓░░█░░ - ░ ▓░█▓█▒ - ░▓ ░ ▒██▓ - █ █░ ▒█░ - ▓ ██░██▒░ - █▒▓ █░▒░░ - ▒ █░▒▓▓ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_13.txt b/codex-rs/tui2/frames/blocks/frame_13.txt deleted file mode 100644 index 7a090e51e33..00000000000 --- a/codex-rs/tui2/frames/blocks/frame_13.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▓████ - ░▒▒░░ - ░░▒░ - ░██░▒ - █ ░░ - ▓▓░░ - █ ░░ - █ ░ - ▓█ ▒░▓ - ░ █▒░ - █░▓▓ ░░ - ░▒▒▒░ - ░██░▒ - █▒▒░▒ - █ ▓ ▒ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_14.txt b/codex-rs/tui2/frames/blocks/frame_14.txt deleted file mode 100644 index f5e74d12b7e..00000000000 --- a/codex-rs/tui2/frames/blocks/frame_14.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ████▓ - █▓▒▒▓▒ - ░▒░░▓ ░ - ░░▓░ ▒░█ - ░░░▒ ░ - ░█░░ █░ - ░░░░ ▓ █ - ░░▒░░ ▒ - ░░░░ - ▒▓▓ ▓▓ - ▒░ █▓█░ - ░█░░▒▒▒░ - ▓ ░▒▒▒░ - ░▒▓█▒▒▓ - ▒█ █▒▓ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_15.txt b/codex-rs/tui2/frames/blocks/frame_15.txt deleted file mode 100644 index f04599ea27d..00000000000 --- a/codex-rs/tui2/frames/blocks/frame_15.txt +++ /dev/null @@ -1,17 +0,0 @@ - - █████░▒ - ░█▒░░▒▓██ - ▓▓░█▒▒░ █░ - ░▓░ ▓▓█▓▒▒░ - ░░▒ ▒▒░░▓ ▒░ - ▒░░▓░░▓▓░ - ░░ ░░░░░░█░ - ░░▓░░█░░░ █▓░ - ░░████░░░▒▓▓░ - ░▒░▓▓░▒░█▓ ▓░ - ░▓░░░░▒░ ░ ▓ - ░██▓▒░░▒▓ ▒ - █░▒█ ▓▓▓░ ▓░ - ░▒░░▒▒▓█▒▓ - ▒▒█▒▒▒▒▓ - ░░ \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_16.txt b/codex-rs/tui2/frames/blocks/frame_16.txt deleted file mode 100644 index 1eb080286ec..00000000000 --- a/codex-rs/tui2/frames/blocks/frame_16.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▒▒█ ███░▒ - ▓▒░░█░░▒░▒▒ - ░▓▓ ▒▓▒▒░░ █▒ - ▓▓▓ ▓█▒▒░▒░░██░ - ░░▓▒▓██▒░░█▓░░▒ - ░░░█░█ ░▒▒ ░ ░▓░ - ▒▒░ ▓░█░░░░▓█ █ ░ - ░▓▓ ░░░░▓░░░ ▓ ░░ - ▒▒░░░█░▓▒░░ ██ ▓ - █ ▒▒█▒▒▒█░▓▒░ █▒░ - ░░░█ ▓█▒░▓ ▓▓░░░ - ░░█ ░░ ░▓▓█ ▓ - ▒░█ ░ ▓█▓▒█░ - ▒░░ ▒█░▓▓█▒░ - █▓▓▒▒▓▒▒▓█ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_17.txt b/codex-rs/tui2/frames/blocks/frame_17.txt deleted file mode 100644 index dd5f5c8da5f..00000000000 --- a/codex-rs/tui2/frames/blocks/frame_17.txt +++ /dev/null @@ -1,17 +0,0 @@ - - █▒███▓▓░█▒ - ▒▓██░░░█▒█░█ ▒█ - ██▒▓▒▒▒░██ ░░░▒ ▒ - ▓░▓▒▓░ ▒░ █░▓▒░░░▒▒ - ░▓▒ ░ ░ ▓▒▒▒▓▓ █ - ░▒██▓░ █▓▓░ ▓█▒▓░▓▓ - █ ▓▓░ █▓▓░▒ █ ░░▓▒░ - ▓ ▒░ ▓▓░░▓░█░░▒▓█ - █▓█▓▒▒▒█░▒▒░▒▒▓▒░░░ ░ - ░ ▒▓▒▒░▓█▒▓░░▒ ▒███▒ - ▒▒▒▓ ████▒▒░█▓▓▒ ▒█ - ▒░░▒█ ░▓░░░ ▓ - ▒▒▒ █▒▒ ███▓▒▒▓ - █ ░██▒▒█░▒▓█▓░█ - ░█▓▓▒██░█▒██ - ░ \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_18.txt b/codex-rs/tui2/frames/blocks/frame_18.txt deleted file mode 100644 index a6c93e6c01d..00000000000 --- a/codex-rs/tui2/frames/blocks/frame_18.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▒▒▒█▒▒█▓░█▒ - ▒█ ▒▓███░▒▒█ █▓▓▒ - ▒▓▓░█ █▒ █ ▓▒ █▓▓▒ █ - █░░█▓█▒ █ █▒░▒▓▒░▒▓▒▒▒█ - ▒▒▓▓ ▓░ ▒ █▒▒▓░▓░▒▒▓▒▒▒ - ▓▒░ ██░▓▒▒▒▓███░█▓▓▒▓░▓░ - ░░▒▓▓ █▓█▓░ ▒▓ █░▒░▒█ - ▒▓░░ ▒▒ ░░▓▒ ░▓░ - ▒ █▒▒▒▓▒▓█░░█░█▓▒█ ░█░░ - ▒▒▒░█▒█ ░░▓▒▒▒▒░░░▒▓░░▒ █ - ░▓░▒░ █████░ ▒▒▒▓░▓█▓░▓░ - ▒▒ █▒█ ░░█ ▓█▒█ - ▒▒██▒▒▓ ▒█▒▒▓▒█░ - █░▓████▒▒▒▒██▒▓▒██ - ░░▒▓▒▒█▓█ ▓█ - ░ \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_19.txt b/codex-rs/tui2/frames/blocks/frame_19.txt deleted file mode 100644 index 73341b5d581..00000000000 --- a/codex-rs/tui2/frames/blocks/frame_19.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▒▒▒▒█░█▒▒░▓▒ - ▒█░░░▒▓▒▒▒▒█▒█░███ - ██▓▓▓ ░██░ ░█▓█░█▓▒ - ▓▓░██▒░ ▒▒▒██▒░██ - ░░▓░▓░ █░▒ ▓ ░▒ ░▒█ - ░▒▓██ ▒░█░▓ ▓▓ █▓█░ - ▒▒░░█ ▓█▒▓░██░ ▓▓▓█░ - ░░░░ ░▓ ▒░ █ ░ ░░░ - ░█░▒█▒▓▓▒▒▒░░░░██▓█░▓ ▒ ░░ - ▒▓▓█░▒█▓▒██▒█░█ ▒▒ ▓▒▒▒█▓▓░▒ - █▒ ▓█░ ██ ▒▒▒▓░▓▓ ▓▓█ - ▒▒▒█▒▒ ░▓▓▒▓▓█ - █ ▒▒░░██ █▓▒▓▓░▓░ - █ ▓░█▓░█▒▒▒▓▓█ ▓█░█ - ░▓▒▓▓█▒█▓▒█▓▒ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_2.txt b/codex-rs/tui2/frames/blocks/frame_2.txt deleted file mode 100644 index 1c7578c970e..00000000000 --- a/codex-rs/tui2/frames/blocks/frame_2.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▒▓▒▓▒█▒▒▒██▒ - ▒██▓█▓█░░░▒░░▒▒█░██▒ - █░█░▒██░█░░ ░ █▒█▓░░▓░█ - ▒░▓▒▓████▒ ▓█▒░▓░█ - █▒ ▓█▒░▒▒▒▒▒ ▒█░▒░█ - █▓█ ░ ░█▒█▓▒█ ▒▒░█░ - █░██░ ▒▓░▓░▒░█ ▓ ░ ░ - ░ ▒░ █░█░░▓█ ░█▓▓░ - █ ▒░ ▓░▒▒▒░ ▓░█████████░▒░░█ - ▒▒█░ ▓░░█ ▓█ ░▒▒▒▒▒▒▓▓▒▒░█▓ ░ - ▒▒▒ █ █▒▓▓░█ ░ ███████ ░██░░ - █▒▒▓▓█ ░ ██▓▓██ - ▓▒▒▒░██ █▒▒█ ▒░ - ░░▒▓▒▒ ██▓▓▒▓▓▓▒█░▒░░█ - ░████░░▒▒▒▒░▓▓█ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_20.txt b/codex-rs/tui2/frames/blocks/frame_20.txt deleted file mode 100644 index 3e0c5f0d9ce..00000000000 --- a/codex-rs/tui2/frames/blocks/frame_20.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▒▒█▒░░▒█▒█▒▒ - █▓▒ ▓█▒█▒▒▒░░▒▒█▒██ - ██ ▒██ ░█ ░ ▒ ▒██░█▒ - ▒░ ▒█░█ ▒██░▒▓█▒▒ - ▒░ █░█ ▒▓ ▒░░▒█▒░░▒ - ▓░█░█ ███▓░ ▓ █▒░░▒ - ▓░▓█░ ██ ▓██▒ █▒░▓ - ░▒▒▓░ ▓▓░ █ ░░ ░ - ░▓░░▓█▒▓▒▒▒▒▒▒▒██▓▒▒▒▒█ ▓ ░▒ - █░▒░▒ ▓░░▒▒▒▒░▒ █▒▒ ░▒▒ █▓ ░░ - ▒█▒▒█ █ ▒█▒░░█░ ▓▒ - █ ▒█▓█ ▒▓█▓░▓ - ▒▒▒██░▒ █▓█░▓██ - ▒█▓▓ ░█▒▓▓█▓ ░ ░█▓██ - ░██░▒ ▒▒▒▒▒░█ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_21.txt b/codex-rs/tui2/frames/blocks/frame_21.txt deleted file mode 100644 index 971877651f3..00000000000 --- a/codex-rs/tui2/frames/blocks/frame_21.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▒▒█▒█▒▒█▒██▒▒ - ███░░▒▒█░▒░█▓▒░▓██▒ - ▓█▒▒██▒ ░ ░▒░██▒░██ - ██░▓ █ ▒█▓██▓██ - ▓█▓█░ █░▓▒▒ ▒▒▒▒█ - ▓ ▓░ ███▒▓▓ ▒▒▒█ - ░█░░ ▒ ▓░█▓█ ▒▓▒ - ░▒ ▒▓ ░█ ░ ░ - ░ ░ ██▓▓▓▓▓███ ▒░█ ░█ ▓▓ ░ - ░ ░▒ ░▒ ▒█░ ▒ ░█░█ ▓ ▓▓ - ▓ ▓ ░░ █░ ██▒█▓ ▓░ █ - ██ ▓▓▒ ▒█ ▓ - █▒ ▒▓▒ ▒▓▓██ █░ - █▒▒ █ ██▓░░▓▓▒█ ▓░ - ███▓█▒▒▒▒█▒▓██░ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_22.txt b/codex-rs/tui2/frames/blocks/frame_22.txt deleted file mode 100644 index 2713fd669e2..00000000000 --- a/codex-rs/tui2/frames/blocks/frame_22.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▒██▒█▒▒█▒██▒ - ▒█▓█░▓▒▓░▓▒░░▓░█▓██▒ - █▓█▓░▒██░ ░ █▒███▒▒██ - ▓█░██ ██░░░▒█▒ - ▒░░▓█ █▒▓░▒░▓▓▓█░ - ▒░█▓░ █░▓░▓▒▓░ ▒░▒▒░ - ░██▒▓ ░█░▒█▓█ ░░▓░ - ░░▒░░ ░▒░░▒▒ ░▒░ ░ - ░░█ █ █░▒▒▓▓▓▒██▒▒█░▒ ▒█ ▒░▓ - ▒░▒ █▒▒▒█ ▓█ ░▓▓░ ▒█▓▒ ░██ ▓▒▒ - ▒▒▒▒░ ██ ░ ░▓██▒▓▓▓ █░ - ▒█▒▒▒█ ▒██ ░██ - █ █▓ ██▒ ▒▓██ █▒▓ - █▓███ █░▓▒█▓▓▓▒█ ███ - ░ ░▒▓▒▒▒▓▒▒▓▒█░ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_23.txt b/codex-rs/tui2/frames/blocks/frame_23.txt deleted file mode 100644 index 39a6c556444..00000000000 --- a/codex-rs/tui2/frames/blocks/frame_23.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▒██▒▒████▒█▒▒ - ▒▒░█░▒▒█▒▒▒█░▒░█░█▒ - █ █░██▓█░ ░▓█░▒▓░░█ - ▓▓░█▓▓░ ▒▓▓▒░░▓▒ - ▓▓░░▓█ █▓████▓█▒░▒ - █▒░ ▓░ ▒█████▓██░░▒░█ - ░░░ ░ ▓▓▓▓ ▒░░ ░██ - ░▓░ ░ ░ ░█▒▒█ ░ █▓░ - ▒ ▒ ░█░▓▒▒▒▒▒▓▒░▒█░▒ ▒▒ ░ ░░░ - ░▒▒▒░ ▒ ▓░▒ ▒░▒▒█░ ▒▒░ - ▓█░ ░ ░ █░▓▓▒░▒▓▒▓░ - █░░▒░▓ █▓░▒▒▓░ - ▒ ░██▓▒▒ ▒▓ ▓█▓█▓ - ▒▒▒█▓██▒░▒▒▒██ ▓▒██░ - ░ █▒▒░▒▒█▒▒██░ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_24.txt b/codex-rs/tui2/frames/blocks/frame_24.txt deleted file mode 100644 index 90ccc262f07..00000000000 --- a/codex-rs/tui2/frames/blocks/frame_24.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▒░▒▓███▒▒█▒ - █ ▒▓ ░▒▒░▒▒██▒██ - █ █▓▒▓█ ░ ▓░▓█░███ ▒ - ██▓▓█▓░▒█▒░░▓░ ▒█▒░▒▒█ - █ ▓▓▒▓█ ░ ▓▒▒░░░▒░██ - ░█▒█▒░ ███▓ ▓░▓ ▓ ▒ - ░ ░░ █▓▒█▓ ▓▒▒░▒▒░▒ - ░ ▒░░ ░█▒▓▒▒░░▒▓▓░░░ - ░▓ ░▓▓▓▓██░░░██▒██▒░ ░ ░░ - ▒ ▓ █░▓██▓▓██░▓▒▒██░ ░█░ - ▒ █▒░▒█ ░ ▒█▓█▒░▒▓█░ - ▒ ▒██▒ ░ ▓▓▓ - ▒▓█▒░░▓ ▒▒ ▒▓▓▒█ - ▓▓██▒▒ ░░▓▒▒▓░▒▒▓░ - █▓▒██▓▒▒▒▒▒██ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_25.txt b/codex-rs/tui2/frames/blocks/frame_25.txt deleted file mode 100644 index d8fd5b45a8f..00000000000 --- a/codex-rs/tui2/frames/blocks/frame_25.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▒█▒█▓████▒ - █ ███░▒▓▒░█░░█ - ▓░▓▓██ ▓░█▒▒▒░░░▒ - ░██░ ▓ ▒░ ▒░██▒▓ - █▒▒▒█▓█▒▓▓▒░ ░▓▓▒▓█ - ▒█░░░▒██▓▒░▓ ▓░█░▓▓░█ - ░▓░█░ ░▒▒▓▒▒▓░▒▓▒ ░▒░ - ░░░▓░▓ ░▒▒▒▓░▒▒░▒░░▒ - ▒█▒░ ░▒▒▒▒▒▒█░░▒▒░██░▒ - ▓▓ ░▓░█░▒░░▓█▒░▒█▒▓▒░ - ▒░█▓▒░░ ██▓░▒░▓░░ - ░▒ ░▓█▓▒▓██▓▒▓█▓▓░▓ - ▒░▒░▒▒▒█▓▓█▒▓▒░░▓ - ▒▓▓▒▒▒█▒░██ █░█ - ░█ █▒██▒█░█ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_26.txt b/codex-rs/tui2/frames/blocks/frame_26.txt deleted file mode 100644 index a4734b4486d..00000000000 --- a/codex-rs/tui2/frames/blocks/frame_26.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▒▓███ ██ - ▓█░▓▓▒░█▓░█ - ▓█ ░▓▒░▒ ▒█ - ▓█ █░░░▒░░▒█▓▒ - ░▒█▒░▓░ █▒▓▓░▒▓ - ▒ ░▓▓▓ █▒▒ ▒▒▓ - ░ ██▒░░▓░░▓▓ █ - ▓▓ ▒░░░▒▒▒░░▓░░ - ░ ▓▒█▓█░█▒▒▓▒░░ - ▓▒░▓█░▒▒██▒▒█░ - ░░ ▓░█ ▒█▓░█▒░░ - ▒▒░░▓▒ ▓▓ ░░░ - █ █░▒ ▒░▓░▓█ - ░ █▒▒ █▒██▓ - ▒▓▓▒█░▒▒█ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_27.txt b/codex-rs/tui2/frames/blocks/frame_27.txt deleted file mode 100644 index b99e90e6d43..00000000000 --- a/codex-rs/tui2/frames/blocks/frame_27.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▓█████ - ░▓▓▓░▓▒ - ▓█░ █░▓█░ - ░░░▒░░▓░░ - ░ ░░▒▓█▒ - ░▒▓▒ ░░░░░ - ▒ ░░▒█░░ - ░ ░░░░▒ ░░ - ░▓ ▓ ░█░░░░ - █▒ ▓ ▒░▒█░░ - ░▓ ▒▒███▓█ - ░░██░░▒▓░ - ░▒▒█▒█▓░▒ - ▒▒▒░▒▒▓▓ - █▒ ▒▒▓ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_28.txt b/codex-rs/tui2/frames/blocks/frame_28.txt deleted file mode 100644 index de6db173b46..00000000000 --- a/codex-rs/tui2/frames/blocks/frame_28.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▓██▓ - ░█▒░░ - ▒ ▓░░ - ░▓░█░ - ░ ░░ - ░ ▓ ░ - ▒░░ ▒░ - ░▓ ░ - ▓▒ ▒░ - ░░▓▓░░ - ░ ▒░ - ░▒█▒░ - ░▒█░░ - █▒▒▓░ - ░ ▓█░ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_29.txt b/codex-rs/tui2/frames/blocks/frame_29.txt deleted file mode 100644 index d7b871c9c33..00000000000 --- a/codex-rs/tui2/frames/blocks/frame_29.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ██████ - █░█▓ █▒ - ▒█░░ █ ░ - ░░░░▒▒█▓ - ▒ ░ ░ ░ - ░█░░░ ▒▒ - ░▒▒░░░ ▒ - ░░▒░░ - ░░░█░ ░ - ▒░▒░░ ░ - █░░▓░▒ ▒ - ░▓░░░ ▒░ - ░░░░░░▒░ - ░▒░█▓ ░█ - ░░█ ▓█ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_3.txt b/codex-rs/tui2/frames/blocks/frame_3.txt deleted file mode 100644 index 833b2b3db2e..00000000000 --- a/codex-rs/tui2/frames/blocks/frame_3.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▒▓▒▓██▒▒▒▒█▒ - ▒██▓▒░░█░ ▒▒░▓▒▒░██▒ - █▓▓█▓░█ ░ ░ ░ ███▓▒░█ - ▓█▓▒░▓██▒ ░▒█ ░░▒ - █▓█░▓▒▓░░█▒▒ ▒▒▒░░▒ - ▓░▒▒▓ ▓█░▒▓▒▒ ░ ▒▒░ - ▒█ ░ ██▒░▒ ░█ ▓█▓░█ - █▓░█░ █▓░ ▓▒░ ░▒░▒░ - ▓ █░ ▓░██░░█▓░▒██▒▒▒██▒░▒ ▓░ - █▒▓▒█ ▓▓█▓▓▓░ ░█░▒▒█ ▒▓█▓▒░░▒░░ - █▒░ ░ ░░██ ███ ███▓▓▓█▓ - ██░ ▒█ ░ ▓▒█▒▓▓ - ▒▒▓▓█▒█ ██▓▓ █░█ - ▒▒██▒██▒▒▓▒▓█▓▒█▓░▒█ - ░███▒▓░▒▒▒▒░▓▓▒ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_30.txt b/codex-rs/tui2/frames/blocks/frame_30.txt deleted file mode 100644 index 9c27cf67d0f..00000000000 --- a/codex-rs/tui2/frames/blocks/frame_30.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▒▓ ████ - ▒▓▓░░▒██▒▒ - █▒░█▒▒░██▒ - ░░▒░▓░▒▒░▒ ▒█ - ▒█░░░▒░█░█ ░ - ░█░▒█ █░░░░▓░ - ▒▓░░░▒▒ ▒▓▒░ ▒░ - ░ ██▒░█░ ░▓ ░ - ░▒ ▒░▒░▒▓░█ ░ - ░░▒░▒▒░░ ██ ░ - ▒░░▓▒▒█░░░█░░ - ░█▓▓█▓█▒░░ ░ - ▒░▒░░▓█░░█░▓ - █▒██▒▒▓░█▓█ - ▒▓▓░▒▒▒▓█ - ░░░░ \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_31.txt b/codex-rs/tui2/frames/blocks/frame_31.txt deleted file mode 100644 index c787451d71c..00000000000 --- a/codex-rs/tui2/frames/blocks/frame_31.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▒▓▓████▒█ - ▒██▓██▒ █▒███ - █░▒▓▓█▒▒░▓ ░▒█▒ - █░▓█▒▒█▓▒█▒▒░▒░░▒ - ▒░░░░█▓█▒▒█ ▒░▓▒▒ - ▓░▒░░▒░█ ▒▓██▓▓░█ ░ - ▓░░ ░▒█░▒▓▒▓▓█░█░▓░ - ▒▒█ ░░ ░▒ ░▒ ░░▒▓░ - ░▒█▒░█▒░░░▓█░░░▒ ░ - ░░░▓▓░░▒▒▒▒▒░▒░░ █ - ▒█▒▓█░█ ▓███░▓░█░▒ - ░░░▒▒▒█ ▒▒█ ░ - ▓░█▒▒ █ ▓ ░█░▓░ - ▓░▒░▓▒░░█░ █░░ - █ ▒░▒██▓▓▓█ - ░░░░ \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_32.txt b/codex-rs/tui2/frames/blocks/frame_32.txt deleted file mode 100644 index e5e7adf64d4..00000000000 --- a/codex-rs/tui2/frames/blocks/frame_32.txt +++ /dev/null @@ -1,17 +0,0 @@ - - █████▓▓░█▒ - ▓█░██░▒░░██░░█ - ▓▒█▒▒██▒▓▓░█░█▒███ - █▓▓░▒█░▓▓ ▓ █▒▒░██ █ - ▓▓░█░█▒██░▓ █░█░▒▓▒█▒█ - ▒▓▒▒█▒█░░▓░░█▒ ░█▓ █ - █░ ▓█░█▒░░██░█▒░▓▒▓▓░█▒ - ░░░█▒ ▒░░ ▓█░▓▓▒ ▒░ ░ - ▒░░▓▒ █▒░ ▒▒░███░░░▒░ ▒░ - █ ▒░░█▒█▒▒▒▒▒▒░░█░▓░▓▒ - █▒█░░▓ ░█ ███▒▓▓▓▓▓▓ - ▒█░▒▒▒ █▒░▓█░ - ███░░░█▒ ▒▓▒░▓ █ - ▒▓▒ ░█░▓▒█░▒█ ▒▓ - ░▓▒▒▒██▓█▒ - ░░░ \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_33.txt b/codex-rs/tui2/frames/blocks/frame_33.txt deleted file mode 100644 index 31a607b29cb..00000000000 --- a/codex-rs/tui2/frames/blocks/frame_33.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▒██▒█▒█▒░▓▒ - ▒██░░▒█▒░▓░▓░█░█▓ - ▒▓▒░████▒ ░ █▓░░█ █ - █▒▓░▓▒░█▒ █░░▒▒█ - ▒▓░▓░░░▓▒▒▒ ░█▒▒▒ - ▓▓█ ▒▒▒▒░▒█ ▓▒▓▒▒ - ░░█ ▒██░▒░▒ ░█░░ - █░██ ███▒▓▒█ ▒ ░█ - ░░░ ░ █░ ▓████▓▒▒█░░█▓▒░▒░ - ▒▓░█ ▓▓█▓░░░▒▒▒▒▒░░█▒▒▒░░▓ - ▒▒▒█ ░▓░▓ ▓ ███ ░░█▓▒░ - ▒█▒██ █ ▓▓▓▓▒▓ - █▒ ███▓█ ▒█░█▓█▒█ - ▒░ █▒█░█▓█▒ ▓█▒█░█ - ▒▒██▒▒▒▒██▓▓ - ░░░░ \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_34.txt b/codex-rs/tui2/frames/blocks/frame_34.txt deleted file mode 100644 index db99cb73d61..00000000000 --- a/codex-rs/tui2/frames/blocks/frame_34.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▒█▒████▒░█▒ - ▒███▓▒▓░ ░██▒██▓█▒▒ - ▒▓▓█░█ ▓░█░ ░▒▒▒█ ███ - █▓▒░█▒▓█▒ █░██▒▒ - ▓▓░▒▓▓░ ░ █ ▒▒█▒▒ - █▓▒░░▓ ▒▒ ░▒█▒ ▒█▒░▒ - ░█▒░▒ █▒▒█░▒▒ ░▓░▒ - ▒░▒ ▓ ░█▒░▓ ░ ▓ ▒▒ - ██▓▓ ▓▒▓▓ ▒▒▒██████░▒▒ ░▒░ - ░░▒█▓██▒ ▓▓█░░░▒░▓▒▒▒█▓▒░░░░▒ - ▓▒▒█ ░▒░█▒ ██░░░░▒ █▓█▒░█ - ▓█▒▓▒▒▒ ▓▓▓░▓█ - ▒█░░█▒▓█ ▒█▒ ▒▓█░ - ▓▒▓░ ░██▓██▒█▒█░██▓█ - ░▒▓▒▒▒▒▒▒▓▒█▒▒ - ░░░ \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_35.txt b/codex-rs/tui2/frames/blocks/frame_35.txt deleted file mode 100644 index 814188563de..00000000000 --- a/codex-rs/tui2/frames/blocks/frame_35.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▒██▓▒███▒██▒ - ██▒█▓░███ ░█░▓ ░█▒▒ - ▒▓▓░▓██░▒█ ░ ░ █▒█▓ ░██ - █▓▓█▓█▓█▒ ██▒▒░▒ - ▓▓░░▓▓▒ ▒██ ░▒█░█ - ▓▓▓▓█░ █░▒ ▓▓█▒ ░▒▒░ - ▒ ▓▓ ▒▒ ██▒▓ ░▒▒▒ - ░░░▓ ▓▒▒▓▓█ ▓ ▓ - ▓ █▒ █░░▓▓ ▓░▒▒▒▓▒▒█░░ ░░▒█ - ░█▒▓█ ▓▓▓ ██▓░▓ ▒█▒▒▒▒▓ ░▓█ ░█ - ▓░▒██▓▒▒░▓▒░ ░ ▒▒▒▒█▒▒█▓▓▒█░ - ▓▒▒▓░ ▒▓█ █▒ - ▒▓░▒▓█▓█ █▓▓▒███ - ▒▒ ░█░▓▓░░█░▓▓█ ▒▓▓ - ▒░▓▒▒▒▓▒▒███ ▒ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_36.txt b/codex-rs/tui2/frames/blocks/frame_36.txt deleted file mode 100644 index cde83b56f41..00000000000 --- a/codex-rs/tui2/frames/blocks/frame_36.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▒▒█▒████▒██▒ - ▒▒ ▒█▓▓▓█▒█▓██ ███▒ - █▒█▒███▓█ ░░ ░ █░██░██░█ - ▒░ ██▒▒▒▒ ██░▒ ░ - █▓▒▓▒█░▒░▒█▓ ▒▒▓█ - ▓ █▓░ █▒ ░▓█ ▒▒█ - ░ ▓ ░ ▒ ▒▒ ░▒░█ - ░░▒░ ▒▒ ▒▓▓ ▒░ ░ - ░█ ░ ▓▓ ██ ████▒█████▒ ░▒░░ - ▒█░▒ █░▒▒▓░▓ ░░▒▒▒▒▒▒▒░░ ▒▓█░ - █ █░▒ █▒█▓▒ ██▒▒▒▒▒ ░█ ▓ - ██ ▒▓▓ █▓░ ▓ - ▒▓░░█░█ ███ ▓█░ - ██▒ ██▒▒▓░▒█░▓ ▓ █▓██ - ░██▓░▒██▒██████ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_4.txt b/codex-rs/tui2/frames/blocks/frame_4.txt deleted file mode 100644 index 7ad27d16e74..00000000000 --- a/codex-rs/tui2/frames/blocks/frame_4.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▒▓▒▓█▒▒█▒██▒ - ▒▓ ██░▓ ░▒▒▓█▓░ ▓██▒ - ██▒░░░██ ░ ░▒░▒▒█░▓▒▒▒ - ▓▓░█░ ▓██ ░██▒█▒ - ▓░▓▒░▒░▒▓▒░█ ▒ ▒░█▒ - ▓░░▒░ █▒░░▓█▒ █ ▒▒░█ - ▒░▓░ ███▒█ ░█ █ ▓░ - ░▓▒ █░▓█▒░░ ░░░ - ▒░ ░▒ ▓░▓ ▒▓▓█░███▒▒▒▒██ ░░█ - ░▒▓ ░ █▓▓▓█▒░░▒▒░█▓▒█▓▓▒▓░▓▓ ░ - ░░▓█▒█▒▒█▒▓ ████████▒▓░░░░ - █░▒ ░▒░ █▒▓▓███ - ▒▒█▓▒ █▒ ▒▓▒██▓░▓ - ░░░▒▒██▒▓▓▒▓██▒██▒░█░ - █▒▒░▓░▒▒▒▒▒▓▓█░ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_5.txt b/codex-rs/tui2/frames/blocks/frame_5.txt deleted file mode 100644 index 24f98439548..00000000000 --- a/codex-rs/tui2/frames/blocks/frame_5.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▒▓▒▓▓█▒▒▒██▒ - ▒█ █▓█▓░░█░▒█▓▒░ ██ - █▒▓▒█░█ ░ ▒▒░█▒ ███ - █░▓░▓░▓▒█ ▓▒░░░░▒ - █▒▓█▓▒▒█░▒▒█ ░ ▒░▒░▒ - ░░░░▓ ▒▒░▒▓▓░▒ █▓░░ - ░▓░ █ ░▒▒░▒ ░█ ██░█░█ - ░▓░▒ █▒▒░▓▒░ █░▒░ - ░█░▒█ ▓▒░ █░█▒▒░█░▒▒▒██▒ ░▓░ - ▒▒░▒██▓██ ░ ▓▓▒▒▒█▒▓█▓░▓█░░ - ▒█░░█░█▒▒▓█░ ██ █░▓░▒▓ - ▒▒█▓▒▒ ░ ▓▒▓██▒ - ▒▓█▒░▒█▒ ▒▒████▓█ - ▒░█░███▒▓░▒▒██▒█▒░▓█ - ▒▓█▒█ ▒▒▒▓▒███░ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_6.txt b/codex-rs/tui2/frames/blocks/frame_6.txt deleted file mode 100644 index fe185a75737..00000000000 --- a/codex-rs/tui2/frames/blocks/frame_6.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▒▓▒▓▓█▒▒██▒▒ - █▒▓▓█░▒██░██▓▒███▒ - ███░░░█ ░ ░▓▒███▓▒▒ - ▓█░█░█▒▒█ ▒█░░░░█ - █▒░░░█▒▒██▒ ▓▒▒░▒█ - ▓▓▓░▓░▒█▓░▒▒░█ ▓▒▒▓░ - ▒ █░░ ▒▒░▓▒▒ ▒█░▒░ - ░ ░░░ ▒░▒░▓░░ ░█▒░░ - ▒▓░▓░ ▓█░░█▓▓█▒░█░▒▒██▒▓▒▓░ - ░░▒█▓▒▒▒▓█ ░▓▒██░░█▓▒▒▒░█░▒ - ▓ ░ ▓░░░▓▓ █ ██ ░▒▒▓░ - █ ▓ ▓█░ █▓▒▓▓░░ - ▓░▒▒███ ▒█▒▒▓███ - ░ ░██ █ ▓░▒▒████ ▓▓█ - ▒▓▓███▒▒▒░▒███ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_7.txt b/codex-rs/tui2/frames/blocks/frame_7.txt deleted file mode 100644 index 7441f97e96e..00000000000 --- a/codex-rs/tui2/frames/blocks/frame_7.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▒▓░▓██▒▒██▒ - ██░█▒░███▒▒▒▓ ░██ - █ █░░░█░ ░▒░░ █▓▒██ - ▒▒░░░░▓█ ▒░▒█░▓█ - ░█░█░░▒░▓▒█ ▓ █░░▒ - ░ ▓░░ ░█▒▓░▒ █▓░░░ - ░▒ ░ ▒▒░▒░▒░ ██▒░░ - ▒ ▓░░ ▒█▓░█░░ █ ░░░ - ▓ ░█ █ ▒▓░▒▓░░▓▓▒░░▒▓█▒░░ - ░██░░▒▓░░▓█░▓▒░░▒▒█▒█▓▒░▒░ - ▒ ▒▒▓█░█▒▓ ██████ ▒▓░░ - █▒ ▓▒▓▒░ █ ▓▓▓▓█ - █▓██▒▒▒▒ █▒░██▓██ - ▒▒█▒░█▒▓░▒▒▒██░██▓ - ░█ ░▓░▒▒█▒▓██ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_8.txt b/codex-rs/tui2/frames/blocks/frame_8.txt deleted file mode 100644 index ea88b095382..00000000000 --- a/codex-rs/tui2/frames/blocks/frame_8.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▒▒█▒▓██▒██▒ - █ █▓░░░█▒▒ ░ █ - ▒░▒█░▓▓█ █ ░▓░█▒█▒█ - ▒█▒█▓░██░ █ ▒▒░░▒ - █ ▓░▓█▒░▓▒ ▓█▒░░█ - ░██░▒▒▒▒▒░▒█ ▒█░░░ - ░█░░░ █▒▓▒░░░ ░▒░▓░█ - ▒█░░▓ ░█▒▓░██▓ ▓░▓░░ - ▒ ▒░░▒▒ ▓█▒░░▓█████▒░░░ - ▒█▓▒▒░ █░█░░▓░▒▒▒░░▒█ - ▓▓▒▒░▒░░░▓█▒█▒█ ▒█ ▓▒░ - ██ ░▒░░░ ▓█▓▓▓█ - █▒▒█▒▒▒▒ ▒▓▒▒░█▓█ - ▓▓█░██ ▓▓██▓▓▒█░░ - ░░▒██▒░▒██▓▒░ - ░░ \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_9.txt b/codex-rs/tui2/frames/blocks/frame_9.txt deleted file mode 100644 index 9066ba1beda..00000000000 --- a/codex-rs/tui2/frames/blocks/frame_9.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▓▒▒█▓██▒█ - ▓█▒▓░░█ ▒ ▒▓▒▒ - ▓ █░░▓█▒▒▒▓ ▒▒░█ - ░░▓▓▒▒ ▒▒█░▒▒░██ - ▓█ ▓▒█ ░██ █▓██▓█░░ - ░ ░░░ ▒░▒▓▒▒ ░█░█░░░ - ░ ░█▒░██░▒▒█ ▓█▓ ░░░ - ░ ░▓▒█▒░░░▒▓▒▒▒░ ░░ - █░ ▓░ ░░░░█░░█░░░ - ░▒░░░▒█░▒░▒░░░░▒▒░░░ - ░▒▓▒▒░▓ ████░░ ▓▒░ - ▒░░░▒█░ █▓ ▒▓░░ - ▒█▒░▒▒ ▓▓▒▓░▓█ - ▒▓ ▒▒░█▓█▒▓▓█░░ - █▓▒ █▒▒░▓█▓ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_1.txt b/codex-rs/tui2/frames/codex/frame_1.txt deleted file mode 100644 index 63249f42421..00000000000 --- a/codex-rs/tui2/frames/codex/frame_1.txt +++ /dev/null @@ -1,17 +0,0 @@ - - eoeddccddcoe - edoocecocedxxde ecce - oxcxccccee eccecxdxxxc - dceeccooe ocxdxo - eedocexeeee coxeeo - xc ce xcodxxo coexxo - cecoc cexcocxe xox - xxexe oooxdxc cex - xdxce dxxeexcoxcccccceco dc x - exdc edce oc xcxeeeodoooxoooox - eeece eeoooe eecccc eccoodeo - ceo co e ococex - eeoeece edecxecc - ecoee ccdddddodcceoxc - ecccxxxeeeoedccc - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_10.txt b/codex-rs/tui2/frames/codex/frame_10.txt deleted file mode 100644 index fe5e51b9845..00000000000 --- a/codex-rs/tui2/frames/codex/frame_10.txt +++ /dev/null @@ -1,17 +0,0 @@ - - eccccecce - ccecccexoeco - eeoxxoxxoxceoo - xeeoexdeoeocceeo - o dxxcxe cooeoxo - xe cxcxooe eecx - e xcccxxxxc xoo - c xxecocxxoeeoexx - c xe eexdxxcecdxx - x oxeoxeoeceeexce - o cxxxxxcc eocexe - eecoeocc exccooo - xc xxxxcodooxoe - deccoxcde ooc - co eceeodc - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_11.txt b/codex-rs/tui2/frames/codex/frame_11.txt deleted file mode 100644 index 48e507a84a1..00000000000 --- a/codex-rs/tui2/frames/codex/frame_11.txt +++ /dev/null @@ -1,17 +0,0 @@ - - occcccce - oc dxxxeeo - oceexxdecoeo - xeexxddoedoo - ecodexcecdexxo - xcexxceddxeoxx - cc oxxxxxxexde - x xxoxxeo xcx - o cxoxxcocxex - cc exodocoxexe - ceo xxxxdoxeex - eeooxecoccdxe - e cxeeeexdc - ec cxxoeoce - ee cccece - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_12.txt b/codex-rs/tui2/frames/codex/frame_12.txt deleted file mode 100644 index 29de69516a3..00000000000 --- a/codex-rs/tui2/frames/codex/frame_12.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ccccco - odeeoxoe - c xoeco - ocxxxddcx - x cxxxxoox - xcoocecexc - x xoexxe - x ocexxc - co xoxxcxx - x oxcdce - xo xcdcco - o cx eox - o ccxocex - ceocoxexe - e cxeoo - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_13.txt b/codex-rs/tui2/frames/codex/frame_13.txt deleted file mode 100644 index 67fe336a137..00000000000 --- a/codex-rs/tui2/frames/codex/frame_13.txt +++ /dev/null @@ -1,17 +0,0 @@ - - occco - xeexx - xeexc - xccxe - c xx - cdoxx - o xx - c cx - oc exo - xc cdx - ceoo xe - xeeex - xcoxe - ceexd - o ocd - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_14.txt b/codex-rs/tui2/frames/codex/frame_14.txt deleted file mode 100644 index f8d32cd6d19..00000000000 --- a/codex-rs/tui2/frames/codex/frame_14.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ccccd - ooeeoe - xexxo x - xxoxcexo - xxxe x - xcxx cx - xxxx o c - xxexe e - xxxx c - ceoo do - exccooox - xcxxeeex - o cxddde - xeoceeo - ec cdo - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_15.txt b/codex-rs/tui2/frames/codex/frame_15.txt deleted file mode 100644 index 2e14341237a..00000000000 --- a/codex-rs/tui2/frames/codex/frame_15.txt +++ /dev/null @@ -1,17 +0,0 @@ - - cccccxe - eodxxedco - ooxcdexccx - xoe ooooeex - xxdcdexxocex - exxoxxoox c - xx xxxxxxox - xxoxxcxxx cox - xxcoocxxxeodx - xexdoxexco ox - xoxxxxex e d - xccoexxeo d - cxeo oooe de - xexxeeoceo - eeceeeeo - ee \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_16.txt b/codex-rs/tui2/frames/codex/frame_16.txt deleted file mode 100644 index c90ce92cb6d..00000000000 --- a/codex-rs/tui2/frames/codex/frame_16.txt +++ /dev/null @@ -1,17 +0,0 @@ - - edcccccxe - oexxcxxexde - xooceodexx ce - ooo dceexexxccx - xxdeoccdxxcoxee - xxxcxc xed x xox - eex oeoxxxxocco x - xod xexxoxxxcd ex - eexxxcxoexxccc o - cceeoddecxoex oex - xxxcccocexdcdoxxe - xxc xe eooo o - exc x oooeox - exxcecxoocex - cdoeddeedc - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_17.txt b/codex-rs/tui2/frames/codex/frame_17.txt deleted file mode 100644 index e1f2bb6d96c..00000000000 --- a/codex-rs/tui2/frames/codex/frame_17.txt +++ /dev/null @@ -1,17 +0,0 @@ - - odcccddxoe - edccxxxcdcxoceo - oceoeddecocxxxece - oxoeoxcee cxdexxxde - xoe x xcoedeoo o - edcooe odox oodoxoo - c dox oooxe ccxxodx - ocdx ooxxoxoxxddc - oocoeddcxeexeedexxx x - xcedeexoceoxxe eccce - eeeoccccccceexcooe ec - exxec eoxxe d - eee cee ocooeeo - o xccdeceedcdxc - ecdoeocxcecc - e \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_18.txt b/codex-rs/tui2/frames/codex/frame_18.txt deleted file mode 100644 index be64251770d..00000000000 --- a/codex-rs/tui2/frames/codex/frame_18.txt +++ /dev/null @@ -1,17 +0,0 @@ - - eddcddcdxoe - eccedoccxeeoccdde - eodxcccdcocoeccooe c - oxxcooecc ceeeodxedeeeo - eeoo ox ecceeoxoxeedeee - oex ooxoeeeoocoxcooeoeox - xxedo cocoxceoccxdxdo - ceoxx eecxxde xdxc - ecc oedddddcxxoxcoeo xcxe - eeexcec xxoeeeexxxedxee o - xoxeeccccccce eeeoxocoeoe - ee oeo eeccocec - eecceeo eceeoeoe - cxoccccdddecceoeoc - cxxeoeeooccdcc - e \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_19.txt b/codex-rs/tui2/frames/codex/frame_19.txt deleted file mode 100644 index 89041571213..00000000000 --- a/codex-rs/tui2/frames/codex/frame_19.txt +++ /dev/null @@ -1,17 +0,0 @@ - - eeddcxcddxoe - ecxxxeodddeceoxcoo - ocddocxcce ecdoecde - odxcoee eddcoexco - xxoeoe oxecocxe xeo - xeocc excxo oo cocx - edxxc oceoxcoe odocx - xxxx xdcexco x xxx - xcxeoddddddxxxxccdcxd e cxx - edooxdcoecceoeo ee deeeoooxe - cecocxcccccccc eeeoxoo ooc - eeecee eooeooc - c eexxco oddooxde - ccoxcoxceeddocc dcxc - cxoedoceooecoe - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_2.txt b/codex-rs/tui2/frames/codex/frame_2.txt deleted file mode 100644 index a3c0663db46..00000000000 --- a/codex-rs/tui2/frames/codex/frame_2.txt +++ /dev/null @@ -1,17 +0,0 @@ - - eoeddcdddcoe - ecoocdcxxxdxxdecxcce - oxcxeccxcee eccdcoxxdxo - exoeoccooe ooexoxo - oecocexeeeee eoxexo - cocce xcecoec eexcx - oxccx eoxdxexo ocxcx - xc ee oxcxxdc xcoox - cccdx dxeeexcoxccccccccoxexxc - edcx oxxc oc xdeeeeeooeexco x - eee c ceooxc ecccccccccxocxx - ceeooo e ocdooc - oeeexco odec exc - exedeecccdddddodceexxc - eccccxxeeeexdocc - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_20.txt b/codex-rs/tui2/frames/codex/frame_20.txt deleted file mode 100644 index cea5393f758..00000000000 --- a/codex-rs/tui2/frames/codex/frame_20.txt +++ /dev/null @@ -1,17 +0,0 @@ - - eecdxxdcdoee - oddcdoeodddxxeececo - oocecccxcc ecececcxce - excecxc eocxeocee - ex oxc eo exxecexxe - oeoxc cccdxco cexxe - dxdcx oc occe oexo - xeeoe ccddxco xxcx - xoxxdoddddddddeocdeeeec o xe - cxexec oeeeeeexe ceecxde oo xx - eoeecccccccccc eodxxox oe - c ecoo eocoxo - eeecoxe odcedcc - eooocxceddodcxceoocc - eccxe deeeexccc - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_21.txt b/codex-rs/tui2/frames/codex/frame_21.txt deleted file mode 100644 index efa6d610d9f..00000000000 --- a/codex-rs/tui2/frames/codex/frame_21.txt +++ /dev/null @@ -1,17 +0,0 @@ - - eeodcddcdcoee - occeeeecxdxcdeeocce - dceeccece eexcceeco - ocxdcc eodcodco - oooce oxoee eeeeo - ocox occeoo eeeo - xcxe e oeooc edec - ee ed cxo x x - x x ocdddddccc exocxo do x - x xe xe eox ececxo ocoo - d co eeccc ce cceod oe o - cc dde ecc o - ce eoe eodcc oe - cde ccccdxxdddccc oe - cccdceeeeoedcce - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_22.txt b/codex-rs/tui2/frames/codex/frame_22.txt deleted file mode 100644 index 91c9c2ecaae..00000000000 --- a/codex-rs/tui2/frames/codex/frame_22.txt +++ /dev/null @@ -1,17 +0,0 @@ - - eocdcddcdcoe - ecocxoeoxoexxdxcocce - odcdxecce ecceccceeco - dcxccc ooxxxece - exxoc oeoxdxoodcx - excoe oxoxdeoe exedx - xcceo xcxecoc xxox - xxdxe xexxee xexcx - xxoco cxddddddcceecxe eo exdc - exd ceeeo oocxoox ecdecxoo oed - eeeex cccccccce edcceooocoe - eceeeo ecocxoc - cccd cce eococceo - cdccoccxddcddodccccoc - cxcxedeeeodeodce - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_23.txt b/codex-rs/tui2/frames/codex/frame_23.txt deleted file mode 100644 index 5b5f1be139d..00000000000 --- a/codex-rs/tui2/frames/codex/frame_23.txt +++ /dev/null @@ -1,17 +0,0 @@ - - eocedccccdcee - edxcxeeoeddoxexcxce - occxcodce cxdcxedxxo - odxcdoe eddexxde - ooxeoc ooooccocexe - oexcoe ecccccoccxxexo - exxcx odoo exe c xcc - xox x xcxoeeo x cox - ece xcxddddddddxecxecee x xxx - xeeexcdc oee exeeox eex - ocx x eccccccc ceoddxeoeoe - oxxexo ooxeeoe - e xocoee eocdcoco - edecdccexddecccoecce - cx cdexeeceecce - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_24.txt b/codex-rs/tui2/frames/codex/frame_24.txt deleted file mode 100644 index c0269d8eda6..00000000000 --- a/codex-rs/tui2/frames/codex/frame_24.txt +++ /dev/null @@ -1,17 +0,0 @@ - - exedcccddoe - oceocxeexddcoecc - occdeoccx oedcxcco e - ocooooxdoeexoe ecexeec - o ooeoo eccoeexeeexoc - xoecee cooo oxd oce - x xx ooeoocoeexeexe - x exx xodoeexxeooexx - xo xddddccxxxccecoex x xx - e o cxoooddooxoeeccx xcx - e cexeccccccce eoocexdooe - e eoce x codo - eoceexo edceodec - oocoeecxxddddxeeoe - cdeccdeeeddcc - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_25.txt b/codex-rs/tui2/frames/codex/frame_25.txt deleted file mode 100644 index 5b040665d0b..00000000000 --- a/codex-rs/tui2/frames/codex/frame_25.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ecdcdcccce - o coceedexcxxo - oxoooocoxcedexxxe - xccx o dx cexoceo - oeeeoocedoexc xooeoc - eoxxxeccoexd oxoxooxo - xoxcx xeeoeeoxeoecxdx - xxxoxoc xedeoxeexdxxe - ecexcxeeddddcxxeexccxe - oocxoxoxexxdcexecdoex - excoexecccccccoxexoxe - xecxdcdeoocdeooooxo - eeexeeecdooeoexxo - eodeeecdxcc cxc - xoccecoecxc - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_26.txt b/codex-rs/tui2/frames/codex/frame_26.txt deleted file mode 100644 index 1592c09e8cf..00000000000 --- a/codex-rs/tui2/frames/codex/frame_26.txt +++ /dev/null @@ -1,17 +0,0 @@ - - edccccco - ocxdoexcdxo - occcxdexecceo - dccoxxxexxecoe - xeoexoxcceodxed - e cxodocceeceeo - x ccdxxoxxddcc - oo exxxeedxxoxx - x oecdcxcddoexx - oexooxeeoceecx - xecoxcceooecexx - eexxoe oocxxe - c cxe eeoxoo - xcceecceccd - eodecxeec - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_27.txt b/codex-rs/tui2/frames/codex/frame_27.txt deleted file mode 100644 index 5279157c040..00000000000 --- a/codex-rs/tui2/frames/codex/frame_27.txt +++ /dev/null @@ -1,17 +0,0 @@ - - dcccco - xddoxoe - dce cxocx - xxxexxdxx - x exeocd - xeoecexxxe - d cxxecxx - x exxxdcxx - xo o xcxxxx - cd ocexecxx - xo eecccoc - xxccxxeox - xddcdooxe - eeexedoo - cec eeo - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_28.txt b/codex-rs/tui2/frames/codex/frame_28.txt deleted file mode 100644 index ea695865f4a..00000000000 --- a/codex-rs/tui2/frames/codex/frame_28.txt +++ /dev/null @@ -1,17 +0,0 @@ - - occd - xcexe - d dxe - xoecx - x xx - x ocx - exx ex - xoccx - oe ex - xxodxx - x ex - xdcdx - xdcxx - ceeox - x ocx - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_29.txt b/codex-rs/tui2/frames/codex/frame_29.txt deleted file mode 100644 index 328d426a415..00000000000 --- a/codex-rs/tui2/frames/codex/frame_29.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ccccco - oxco ce - eoxx ccx - xxxxeeoo - e xcx x - xoxxx ee - xeexxx e - xxdxx - xxxcx e - exdxx e - cxxoxe d - xoxxx ex - xxxxexex - xdxcocxc - xxc oo - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_3.txt b/codex-rs/tui2/frames/codex/frame_3.txt deleted file mode 100644 index 3e9206577af..00000000000 --- a/codex-rs/tui2/frames/codex/frame_3.txt +++ /dev/null @@ -1,17 +0,0 @@ - - eoddccddddoe - ecooexxcxcddxdeexcce - odocdxccce ecx cccoexo - ocoexdoce edc xxe - cocxoeoxxcee eeexxe - oxeeo ooxedee x eex - dc x ccexecxo ocoxo - ooxox ooxcoex xexdx - occx dxccxxcoxdcceeeccexecdx - oedeo oocoddx xcxeeo doodeexexe - cex x cxxcoc cccccccccoooooo - ccx ec e oeceoo - deooceo ocdocoxc - decoecceddddoddcdeecc - ecccedxeeeexdoec - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_30.txt b/codex-rs/tui2/frames/codex/frame_30.txt deleted file mode 100644 index b9da98c5c37..00000000000 --- a/codex-rs/tui2/frames/codex/frame_30.txt +++ /dev/null @@ -1,17 +0,0 @@ - - edcccco - eodxxeccde - ccexoeexcoe - xxexoxeexe eo - dcxxeexoxo x - xcxec cxxxxox - eoxxxee eoex de - cx ccdxoxcxo e - cxecexdxeoxo e - cxxexeexx co e - exxdeecxxxcxx - xcoooocexxc x - exexxocxxoxo - oeocdeoxooc - eooxeeedc - eeee \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_31.txt b/codex-rs/tui2/frames/codex/frame_31.txt deleted file mode 100644 index baef07474cb..00000000000 --- a/codex-rs/tui2/frames/codex/frame_31.txt +++ /dev/null @@ -1,17 +0,0 @@ - - eodccccdo - eccdcoeccecco - oxeooodeeocxece - oxoceecdeoeexexxe - exxxxcoceeocexoee - dxeexexccedcoooxocx - oxx xecxeododcxcxox - eeo xxcxe xeccxxeox - xeoexcexxxocxxxe x - cxxxooxxeeeeexexx c - eceocxo occceoxcxe - xxxeeeo edc x - dxcde o o xceoe - dxexoexeoxcoxe - ccdxeccoodc - eeee \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_32.txt b/codex-rs/tui2/frames/codex/frame_32.txt deleted file mode 100644 index c0997d9a140..00000000000 --- a/codex-rs/tui2/frames/codex/frame_32.txt +++ /dev/null @@ -1,17 +0,0 @@ - - occccddxoe - dcxccxexxccxxo - oecdeocedoecxcecco - cooxeoedo o oeexco o - ooxoxceccxd ceoxeoeceo - eoeeoecxedxxce xco c - cxcdoecexxooxodeoeooxce - xxxoe cexxcocxdoecexcce - exxoe cexceexcccxxxdxcde - ccceexceceeeeeexxcxdxoe - oecxxo xccccccedooooo - eoxeee oexocx - cccxxxce eoexo o - eoecxcxddceecceo - xddeeococecc - eee \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_33.txt b/codex-rs/tui2/frames/codex/frame_33.txt deleted file mode 100644 index cd8691c1502..00000000000 --- a/codex-rs/tui2/frames/codex/frame_33.txt +++ /dev/null @@ -1,17 +0,0 @@ - - eocdcdcdxoe - eccxxecdxdxoxcxco - eoexcccodce ccoxxcco - oeoxoexoe cxxeec - eoxoexxoeee xceee - ooo eeeeeeo oeoee - xxc eocxexe xcxx - cxoo occeodo ecxc - xxx x oe ocooodddcxxcoexex - eoxccodooexxeeeeexxceeexxo - edeo xoxo o ccccccc xxooee - ececoco oododo - ceccocdo ecxoocec - exccecxodcecdoecxc - cddcoeeeeccdoc - eeee \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_34.txt b/codex-rs/tui2/frames/codex/frame_34.txt deleted file mode 100644 index ef8eabf7dc0..00000000000 --- a/codex-rs/tui2/frames/codex/frame_34.txt +++ /dev/null @@ -1,17 +0,0 @@ - - eodccccdxoe - ecccoedxcxccdccdode - eoooxccdxce eeeeoccco - ooexceooe cxocee - ooxeoox xc o eecee - ooexeo eecxece eoexe - xcexe ceecxee xdxe - exdcd xcexocx o ee - ocooc oeooceddccccccxeec xee - xxeooooecoocxxxexoeeeooexxexe - oeeo xexce ccceeeee oooexc - ooeddee odoxoc - ecexcedo ecdceooe - oeoxcxcodocdcdceccdc - cxeddeeeeeddcde - eee \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_35.txt b/codex-rs/tui2/frames/codex/frame_35.txt deleted file mode 100644 index 1c53d2373f2..00000000000 --- a/codex-rs/tui2/frames/codex/frame_35.txt +++ /dev/null @@ -1,17 +0,0 @@ - - eocddcccdcoe - ocdcoxccccxcxdcxcde - eooxdccxecce eccdcocxco - ooocoodoe cceexe - ooxxooe ceco xecxo - dodoce cxecooce xeex - e oo ee cceo xeee - xxxd oeedoc o co - o oe oxxodcoxddededcxx xxdc - xoedc oodcccoxd eoeeeeocxoc xc - oeeocoeexoee eceeeeceecooeox - coeeox eoc oe - edeedodo odoeccc - ceecxcxodxxcxdocceodc - cexddeeoeecccce - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_36.txt b/codex-rs/tui2/frames/codex/frame_36.txt deleted file mode 100644 index 4928a2a9d07..00000000000 --- a/codex-rs/tui2/frames/codex/frame_36.txt +++ /dev/null @@ -1,17 +0,0 @@ - - eecdccccdcoe - edccecodocecdcccccce - oeceoccoccee eccxccxocxo - exccceeee ccxecx - ooedecxeeeoo eeoc - o ooe ce cxoo ceec - x d e e cee xexo - xxex ee eoo ex x - xccx oo occcocceccccce xexx - ecxe oxeeoxo exeeeeeeexx eoce - c cxe cecoe ccceeeeec xoco - cocedo ooxco - eoxxcxo occcooe - coecccdedxecxdcocodcc - eccoxeooeooccccc - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_4.txt b/codex-rs/tui2/frames/codex/frame_4.txt deleted file mode 100644 index a5ae50eeae4..00000000000 --- a/codex-rs/tui2/frames/codex/frame_4.txt +++ /dev/null @@ -1,17 +0,0 @@ - - eoddcddcdcoe - eocccxo xedocdxcocoe - ocdxxxccce eexeecxoeee - ooxoxcoco cxccece - oxoexdxedexo ecexce - oxxex cexxoce c edxo - cexde ccceccxo o cdx - xoe oxocexx xxx - ex xe dxoceoocxccceeeeoo xxc - xeo x oooooexedexooeodoedxoocx - exdceoeeoeo ccccccccceoxxxe - oxecxee oedoccc - eeode oe eoeocdxo - xxxdecceddddocdccexce - ceexdxeeeeedoce - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_5.txt b/codex-rs/tui2/frames/codex/frame_5.txt deleted file mode 100644 index 47abf7a0af6..00000000000 --- a/codex-rs/tui2/frames/codex/frame_5.txt +++ /dev/null @@ -1,17 +0,0 @@ - - eodddcdddcoe - ecccocoxxcxdcdexcco - ceoecxcce cedxce oco - oxoxoxodo oexxxxe - oeocoeecxeeo e exexe - xxxxo eexedoxe coxx - xox c eeexecxo ccxcxo - xoxec oedxoex cxex - xoxec oexcoxcdexcxeeecoecxox - eexeoooccc xc ooedeodoooxocxe - eoxxoxoeeoce ccccccccoxdxeo - eecoee e oeocoe - eocexdce edcoccdc - excxoccedxdeocdcexdc - eocecceeeoeocce - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_6.txt b/codex-rs/tui2/frames/codex/frame_6.txt deleted file mode 100644 index ba04c52772f..00000000000 --- a/codex-rs/tui2/frames/codex/frame_6.txt +++ /dev/null @@ -1,17 +0,0 @@ - - eodddcddccee - oedocxdccxccdeocce - oooxxxcc ecxodcccoee - ooxcxcedo ecxxxxo - cdxxxceecce oeexeo - dooxoeecdxeexo odeox - e cxx eexoee ecxex - x xxx exdxoxx xcexx - eoxox ocxxcdocexcxeecceoeox - xxeooeeedc xodcoxxodddexoxe - o x oxxxdoc cccccccceeeox - o d ooe odeooxe - oeeecco eceeococ - ecxcocc dxeeoccc ddc - eodccoeeeeeocc - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_7.txt b/codex-rs/tui2/frames/codex/frame_7.txt deleted file mode 100644 index f7dd0de9b60..00000000000 --- a/codex-rs/tui2/frames/codex/frame_7.txt +++ /dev/null @@ -1,17 +0,0 @@ - - eoxdccddcoe - ocecexcccdded eco - c oxxxce eexe coeco - eexxxxdc execxoo - ecxcxxexoeo o cxxe - x dxxc ecedxe ooxxx - eecx eexexex ccexx - ecoxx dcoxcxe c xxx - ocxc oceoxeoxxddexxddcexx - xocxxddxxocxoexxeeododexex - e deocxceo cccccccceoxx - ce oeoee ocoodoc - cdoceeee oexococc - eecexcedxeeeccxcco - cxccxdxeeoedcc - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_8.txt b/codex-rs/tui2/frames/codex/frame_8.txt deleted file mode 100644 index e3f93702f72..00000000000 --- a/codex-rs/tui2/frames/codex/frame_8.txt +++ /dev/null @@ -1,17 +0,0 @@ - - eecedccdcoe - occoxxxcdd x cc - exdoxooc ocxoxceceo - ececoxocx c dexxe - c oxooexoe ocexxo - xcoxeeeeexec ecxxx - xcxxx cedexex xexoxo - eoxxo xceoxoco oeoxx - e exxee ocdxxococooexxx - ecodexcoxoxxdxdeexxdc - ooeexexxxocececceccoex - cocxexee oooooc - ceeceeee eoeexcoc - odcxoc ddccdodoxe - xxeccexeocode - ee \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_9.txt b/codex-rs/tui2/frames/codex/frame_9.txt deleted file mode 100644 index 210e417d435..00000000000 --- a/codex-rs/tui2/frames/codex/frame_9.txt +++ /dev/null @@ -1,17 +0,0 @@ - - odecoccdo - oceoxxccd eoee - o oxxoceedo eexo - c xxodde eeoxeexco - occdeccxco coccdcxx - x xxxcexedee xcxcxxx - e xcexccxeeocooo exx - x xoeoexxxeodeex xx - coxc oxcxxxxcxxoxxe - xexxxeoxexexxxxeexxx - c eeoeexocccccxxcoex - exxxeoe oo eoxe - ecexee odedxoc - eoceexcocdddcxe - coe ceexdcoc - \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_1.txt b/codex-rs/tui2/frames/default/frame_1.txt deleted file mode 100644 index 64a140d2b9c..00000000000 --- a/codex-rs/tui2/frames/default/frame_1.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -             _._:=++==+,_              -         _=,/*\+/+\=||=_ _"+_          -       ,|*|+**"^`   `"*`"~=~||+        -      ;*_\*',,_            /*|;|,      -     \^;/'^|\`\\            ".|\\,     -    ~* +`  |*/;||,           '.\||,    -   +^"-*    '\|*/"|_          ! |/|    -   ||_|`     ,//|;|*            "`|    -   |=~'`    ;||^\|".~++++++_+, =" |    -    _~;*  _;+` /* |"|___.:,,,|/,/,|    -    \^_"^ ^\,./`   `^*''* ^*"/,;_/     -     *^, ", `              ,'/*_|      -       ^\,`\+_          _=_+|_+"       -         ^*,\_!*+:;=;;.=*+_,|*         -           `*"*|~~___,_;+*"            -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_10.txt b/codex-rs/tui2/frames/default/frame_10.txt deleted file mode 100644 index 9d45417346b..00000000000 --- a/codex-rs/tui2/frames/default/frame_10.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -              _+***\++_                -             *'`+*+\~/_*,              -            ^_,||/~~-~+\,,             -           |__/\|;_.\,''\\,            -           / ;||"|^  /_/|/            -          |` '|*~//\   `_"|            -          \  ~*"*||~|*   |/,           -          "  ||\+/+||-_ .\||           -          "  ~\ \\|;~~+\+;||           -          |  ,|\,|_/_*___|*`           -           , "|||||""!\,"\|`           -           \`',\,*"  "",//            -            |' |||~*,:,/|/`            -             ;`**/|+;_!//'             -              *, _*\_,;*               -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_11.txt b/codex-rs/tui2/frames/default/frame_11.txt deleted file mode 100644 index 769e5ae76d7..00000000000 --- a/codex-rs/tui2/frames/default/frame_11.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -               ,****++_                -              /" ;|||\\,               -             /"__||;\*/\,              -             |__||=;,_=//              -            _".;\|+\';_||,             -            |+`||+_;;|_/||             -            ** ,||||||_|=\             -            |  ||/||\/ |"|             -            /  '|/||*/+|_|             -            ** _|/=,"/|_|^             -            '`- ||||=/|\\|             -             \_-/|_*/**;|`             -             !_ *|\\^_|;"              -              \+!*||,_/*`              -               \_ '*+_+`               -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_12.txt b/codex-rs/tui2/frames/default/frame_12.txt deleted file mode 100644 index 50cfd73302d..00000000000 --- a/codex-rs/tui2/frames/default/frame_12.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -                +***+.                 -               ,=`_/|,\                -               "  |/\+,                -               /+~||=="|               -              | '~|||./|               -              |'..*^"_|"               -              |   ~/\||\               -              |   /+\||"               -              *, ~/||+|~               -              |   /|*;*_               -              |.  |"=**/               -               ,  *|!_,|               -               / **|,*\|               -               '^/",|\|`               -                \ '~\./                -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_13.txt b/codex-rs/tui2/frames/default/frame_13.txt deleted file mode 100644 index 04ed71335c1..00000000000 --- a/codex-rs/tui2/frames/default/frame_13.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -                 /***,                 -                 |__||                 -                 |`_|"                 -                 |**|_                 -                 *  ||                 -                 ":-||                 -                 ,  ||                 -                 +  "|                 -                /+  _~.                -                |"  +=|                -                '`.. ~`                -                 |___|                 -                 |+,|_                 -                 *__|=                 -                 , ."=                 -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_14.txt b/codex-rs/tui2/frames/default/frame_14.txt deleted file mode 100644 index 66e91f7187b..00000000000 --- a/codex-rs/tui2/frames/default/frame_14.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -                 +***;                 -                 ,/__.\                -                |_||. |                -                ||/|"^~,               -                |||\   |               -                ~*||  '|               -                |||| . *               -                ||\|`  \               -                |||~   "               -                "^//  ;/               -                \|"",.,|               -                |*~|___|               -                /!"|===`               -                |\/*__/                -                 _* '=/                -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_15.txt b/codex-rs/tui2/frames/default/frame_15.txt deleted file mode 100644 index 9d8132e3c41..00000000000 --- a/codex-rs/tui2/frames/default/frame_15.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -                 ++***~_               -                `,=||^:*,              -               //|*=\|"*|              -               |/` //,.__|             -              ||="=\||/"^|             -              \||-||//|  "             -              ||   ||||~~,|            -              ||/~|+||| '-|            -              ||+,,*|||_.:|            -              |_|;/|\~*. .|            -              |/||||_| ` ;             -              |**.^~|\-  =             -              '|\, ///` ;`             -               |^||\\.+\/              -                \^*^___/               -                   ``                  \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_16.txt b/codex-rs/tui2/frames/default/frame_16.txt deleted file mode 100644 index 7217fe58b8e..00000000000 --- a/codex-rs/tui2/frames/default/frame_16.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -                _=+"**+~_              -               /^||*||\|=\             -              |//"\/=\|| '\            -             /// ;' \|\||**|           -             ||;_ =||*/|`\           -            |||*|  /|= !| ~.|          -            \\|  ,||||/*", |          -            |/; |`||/|||"; `|          -            \\|~|+~/^||"*+  /          -            *"__,==\*|._| ,_|          -            |||+""/*\|;";.~|`          -             ||* |   `//,  /           -             \|*  |  /,/_,|            -              \|~"_*~//+_|             -               ':._=:__;*              -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_17.txt b/codex-rs/tui2/frames/default/frame_17.txt deleted file mode 100644 index 0d873df7518..00000000000 --- a/codex-rs/tui2/frames/default/frame_17.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -                ,=+++;;~,_             -             _;**|~~*=*|,"^,           -            ,*\/_==`+,"|||_"\          -           /|/_/|"   |;\~||=\         -           |/_ ~     "/\=\//  ,        -          `=*,/`   ,:/| /,=/|./        -          *!;/|   ,//|_ *"||/=|        -          -"=|!   !//||/ ,||=;*        -          ,/*/\==+~\_|\^:\||| |        -           |"_;__|/*\/||\!\+'+\        -          \\\/"""****\_|*//\ \'        -           \||_*       `/||` ;         -            _\\!*\_   ,',/^_/          -             , ~*+=\+`_;*:|'           -              `+;/_,+~*_+*             -                   `                   \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_18.txt b/codex-rs/tui2/frames/default/frame_18.txt deleted file mode 100644 index a474a4f3d03..00000000000 --- a/codex-rs/tui2/frames/default/frame_18.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -               _==+==+;~,_             -            _+"_;,++~__,"+;;_          -          _/:|*"*=" "._"+//\ *         -         ,||*.,^" _/=~\;\\\,       -        _\// /|   _\/~/|_\;\\_       -        /\| ,, _/,*,|'-/^/`/~!      -        ||\:/     +/*/|"_/"*|=|=,      -        "\-~|     ^\"||;^   |;|"       -       \"" ,\==;=;+~|,|*/\, |*|`       -        _\\|*\* ~|/__\_~||_;~`\ ,      -        |/|\`""*****` \__/|/*/`-`      -         \\!,\,         ``*"/*_'       -          \^*+^^.      _*^_/\,`        -           '|.**++===^'*_/_,*          -             "~|_/__,.+";+"            -                    `                  \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_19.txt b/codex-rs/tui2/frames/default/frame_19.txt deleted file mode 100644 index e83b78bd3ba..00000000000 --- a/codex-rs/tui2/frames/default/frame_19.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -              __==+~+==~._             -           _+|||\/===_*_,|*,,          -         ,*;;/"|+*`    `*:,`*;\        -        /;|*,^`         _==+,^|*,      -       ||/`/`         ,|^"/"|\ |\,     -      |\/*'         _|*~/!./ '.*|      -      ^=|~'        /*^/|+,`   /:/+|    -      ||||         |;"\|",    | |||    -      |*|\,=;;===~~~|+*;*|;   \ "||    -      ^;/,|=*/^*+\,`, ^_ :\_\,/.|_     -      '\"/+|"""""**"   ^\_/|// //'     -       \\^*_\             `//_//'      -        '!_\~~*,        ,;=./|;`       -          '".|*/~+__=;/*" ;*~'         -             "~._:-'_,.^*-^            -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_2.txt b/codex-rs/tui2/frames/default/frame_2.txt deleted file mode 100644 index ac205dd4a51..00000000000 --- a/codex-rs/tui2/frames/default/frame_2.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -             _._:=+===+,_              -         _+,/*;+||~=~|=_'|*+_          -       ,|*|\**~*``  `"*=*/||;|,        -     _|/_/*',,_           -,\|/|,      -     ,^"/*^|\_\\_           ^,|\|,     -    '/+"`  |*\+/\+           \\|*|     -   ,|'*|    ^/|;|_|,          /"|"|    -   |" \`     ,|*||;*          |'/.|    -   *""=|    ;|^^_|".~++++++++,|_|~*    -    _='|  /||' /* |=\____..__|+/!|!    -    \\\ * *\..|'   `"*******"|,*||     -     '\_./, `              ,+;/,*      -       .\__|+,          ,=_+!_|"       -        `~^;__"*+:;=;;.=*`_||*         -           `*+*+~~____~;/*"            -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_20.txt b/codex-rs/tui2/frames/default/frame_20.txt deleted file mode 100644 index bff8cc065f9..00000000000 --- a/codex-rs/tui2/frames/default/frame_20.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -              __+=~~=+=,__             -          ,;=";,_,===||_^*\+,          -        ,,"_+*"~*"    `"^"\+*|+_       -      _|"_*|*           _,+|\/*\\      -     _| ,|*           _/!_||^*\||\     -     /`,|'           +*';|"/  '\~|\    -    ;|;+|          ,* .+*^     ,\|/    -    |_^/`          "";:|",     |~"|    -    |/||;,=;======_,';^^\\*    / |\    -    '|^|_" /``____|\  *\\"|=\ ,/ ||    -     \,^\'"""""""*"     \,=||,| /\     -      * ^*/,               _/*.|/      -        \_^*,~_          ,;*`;*'       -          ^,-."~+^;;,:"~"`,/*'         -            `*+~_!=____|*""            -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_21.txt b/codex-rs/tui2/frames/default/frame_21.txt deleted file mode 100644 index b23aadbc7c7..00000000000 --- a/codex-rs/tui2/frames/default/frame_21.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -             __,=+==+=+,__             -          ,+*``__+~=~+;_`-*+_          -        ;*^_+*^"`     `^~*+_`*,        -      ,*|;"'             _,;*,;*,      -     /,/*`             ,|/_\ \\_^,     -    /"/|             ,**_//    \\^,    -    |'|`           _!/`,/'     \;\"    -     `\            \; "|,       | |    -    | |  ,+;;;;;+++  ^|,"|,    ;/ |    -    | ~\ |_      _,|   ^"`*|,  /"./    -     ; ". ``"""  '`     '*_,; /` ,     -     '+ :;_                 _*" /      -       *_  ^-_          _.;*' ,`       -         *=_ *"*+:~~;:=*""  -`         -            *++;+____,_;+*`            -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_22.txt b/codex-rs/tui2/frames/default/frame_22.txt deleted file mode 100644 index ccc8480d8b1..00000000000 --- a/codex-rs/tui2/frames/default/frame_22.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -             _,+=+==+=+,_              -         _+/*|._/|/\||;|+/*+_          -       ,;*;~\**`    `"*\**+__+,        -      ;*~**"            ,,|||_*\       -     \|~/'            ,_/|=|./;'|      -    \|*/`           ,~/|;^/` \|\=|     -   |+*\/           ~+|\*/'    ~|/|     -   ||=|`           |\|~^\     |^|"|    -   ||,", +~==;;;=++_\*|_!\,   _|;"!    -   ^|= *_\\,!/,"~//|  \*;_"|,,!/\=     -    \\\_| """""**"`    `:*+_///",`     -     \*\_\,               _*,"|,'      -      '"+; ++_         _.*,"*_/        -        ':*+,"*~;=+;;/=*""',*          -           "~"~_;___-=_.=*`            -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_23.txt b/codex-rs/tui2/frames/default/frame_23.txt deleted file mode 100644 index 406ced01b08..00000000000 --- a/codex-rs/tui2/frames/default/frame_23.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -            _,+_=+*++=+__              -         _=|+|\_,_==,|_|*|+_           -       ,"+|',;*`    "~:+|\;||,         -      /;|*;/`          _;;\||;\        -     //|`/'          ,/,,'*/*\|\       -    ,\|"/`         _***''/*'|~\|,      -    `||"|         /:/. _|`  "!|'*      -    ~/| |         |"|,_\,   | */|      -    ^"\ |+~;=====;=|_*|_"\_ | |~|      -    |\\_|"="       /`\ \|_\,~ \_|      -     /'| | `"""""""   '`/;=|_/^/`      -      ,||_|.             ,/|\^/`       -       \ |,'/__       _.";*/+/         -         \=\+;*+\~==_++"-_+*`          -           "~!*=\~__+__**`             -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_24.txt b/codex-rs/tui2/frames/default/frame_24.txt deleted file mode 100644 index 73f56393902..00000000000 --- a/codex-rs/tui2/frames/default/frame_24.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -             _~_;++*==,_               -          ,"_/"|__~==+,_'+             -        ,"';\/+"~ .`:*~**, \           -       ,'//,/|=,\`~/`!_*\|\_'          -      , //\/,    `""/\_|``\|,'         -      ~,\+\`       *,,/!.|;!/"\        -     |  |~!      ,/_,/"/^\|\^|^        -     | \||       |,=/\_|~_/.`||        -     |. |;;;:++~~~++_*,_| |  ||        -      _ / !*|/,,;;,,|.^\+*| |*|        -      \ '\|\*""""""` \,.*\|=/,`        -       \ \,*\           |!"/;/         -        \.*\`|.      _="_/:_*          -          .-*,\^"~~:==;|^_/`           -            *:_*+;\__==+*              -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_25.txt b/codex-rs/tui2/frames/default/frame_25.txt deleted file mode 100644 index 6fb0cbc16cf..00000000000 --- a/codex-rs/tui2/frames/default/frame_25.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -             _+=*;++++_                -           ,!*,*`_;\|*||,              -          /|//,,".|+\=\|||_            -         |+*| /! =| "\|,*\/            -        ,__\,/'^;/_|" |//\/*           -        \,~|~_*+.^|: /|,|//|,          -        |/|*| |__/\_/|\/_"|=|          -        |||/|."!~_=\/|\_~=||\          -       ^+\|"|__====+~|\\|+*|\          -        /-"|/|,|_||;*_|\*=/\|          -        \~*/\|`"""""'+/|\|/|`          -         |_"|;+;\-,*:_/,//|/           -          ^`^|_\_*;/,^/_||/            -           \.;\\_*=|**!*|*             -             ~,"*\+,_+|*               -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_26.txt b/codex-rs/tui2/frames/default/frame_26.txt deleted file mode 100644 index 8bd6052839d..00000000000 --- a/codex-rs/tui2/frames/default/frame_26.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -              _;***"+,                 -             /*|;/\|+;|,               -            /*""|;\|\""\,              -           ;*",||~_||_+/^              -           |_,\|.~"*\/;|\;             -           \ "|/:/"*_\"\\/             -          |!  *'=||/||;;"+             -          /-  \|||^^=||/||             -          |  .\*;+~+==/\||             -          ! ._|/,|__,*\\*|             -           |`"/|*"\,/`+\||             -           \_~|/\   //"||`             -            *  *|\ ^`/|/,              -             |"*\\"*_**;               -              \/:^*~_\*                -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_27.txt b/codex-rs/tui2/frames/default/frame_27.txt deleted file mode 100644 index e8630695b8d..00000000000 --- a/codex-rs/tui2/frames/default/frame_27.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -                ;***+,                 -               |;:/|/\                 -              ;'` *|/'|                -              |~~^||;|~                -              |  `|_-'=                -              ~_._"`|||`               -              = "||_*||!               -             |  `||~="||               -             |. .!|+||||               -             '= ."_|_*||               -              |- _^**+/'               -              ||++||_/|                -              |==+=,/|^                -               \__|\=//                -               '\" ^\/                 -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_28.txt b/codex-rs/tui2/frames/default/frame_28.txt deleted file mode 100644 index 3313d8b9bf7..00000000000 --- a/codex-rs/tui2/frames/default/frame_28.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -                 /**;                  -                 |+_|`                 -                 = ;|`                 -                 |-`*|                 -                 |  ~|                 -                 | -"|                 -                ^|~ _|                 -                 |-""|                 -                /\  _|                 -                ||.:~|                 -                 |  _|                 -                 |=+=|                 -                 |=*||                 -                 *__/|                 -                 ~ .+|                 -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_29.txt b/codex-rs/tui2/frames/default/frame_29.txt deleted file mode 100644 index 2ae088f1b90..00000000000 --- a/codex-rs/tui2/frames/default/frame_29.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -                 +****,                -                ,|*/!*\                -                ^,|| '"|               -                ||||^\,/               -                \ ~"|  |               -                |,~|| __               -                |\\||| ^               -                ||=~|                  -                ||~*|   `              -                _|=||   `              -                *||/|^ =               -                |/~~| _|               -                ~|||`~_|               -                |=|*/"|'               -                 ~|* .,                -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_3.txt b/codex-rs/tui2/frames/default/frame_3.txt deleted file mode 100644 index 727e25a8e89..00000000000 --- a/codex-rs/tui2/frames/default/frame_3.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -             _.=;++====,_              -         _+,/\||+|"==|;_^|*+_          -       ,;/*;|*""`   `"~!**+/^|,        -      /+/\|;,+_          `=*!||\       -     '/*|/^/||*\_           \^\||_     -    /|\\/  .,|\;\\           | \\|     -    =* |    '*\|_"|,          .*/|,    -   ,-|,|     ,-|"/\|          ~_|=|    -    -"'|    ;|*+~|*-~=++___++_~^";|    -    ,\:\, ./*/;;| |*|__,!=.,;\`|\|`    -    '^| | "||+,"   "***"""**,///,/     -     '*~ \+ `              /^+^//      -       =^//*\,          ,+;/",|'       -         =^+,_**^=;=;,:=*;`_+"         -           `*+*_:~____~;/^"            -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_30.txt b/codex-rs/tui2/frames/default/frame_30.txt deleted file mode 100644 index 99eeebce339..00000000000 --- a/codex-rs/tui2/frames/default/frame_30.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -                 _;"**+,               -               _/;||\*'=\              -               "'^|,\\|+,\             -              ||\|/|_\|\ \,            -              =*||`\|,|,  |            -             |*|^+  *||||.|            -             \/|||\_ \/\| =`           -             "| '+=~,|"|-  `           -             "|_"\~=~\/|,  `           -             "||\|__~|!+,  `           -             !\||;\_*~||+~|            -              |*//,/*\||" |            -              \|\||/*~|,~/             -               ,^,+=^/|,/'             -                \-.|^__;'              -                  ````                 \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_31.txt b/codex-rs/tui2/frames/default/frame_31.txt deleted file mode 100644 index 8d9adf28b24..00000000000 --- a/codex-rs/tui2/frames/default/frame_31.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -                _.:*+*+=,              -              _+*;+,_"+\'*,            -             ,|\//,=_`."|_*\           -            ,~/+__*;_,\\|\~|\          -            ^||||+-*\_,"\|/__          -           ;|^`~_|'"\;*,./|,"|         -           /|| |^*|\.=/;*|*|/|         -           \\, |~"|\ |^""|~\.|         -           |\,_|'^~|~/+~~|_  |         -           "||~//||___\_|\|| *         -           ^*_/+|, /***`/|'~_!         -            |||\\\,     _=* |          -             ;|*=_!,  . |*`/`          -              :|\|/_|`,|",|`           -               '"=~_+*/.;*             -                   ````                \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_32.txt b/codex-rs/tui2/frames/default/frame_32.txt deleted file mode 100644 index 4175a7a66ef..00000000000 --- a/codex-rs/tui2/frames/default/frame_32.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -                ,++++;;~,_             -              ;*~**~\||**|~,           -            /^*=^,+^:-`*|*\'*,         -           '//|_,`;- - ,_\|+,!,        -          //|,|*\'*|; '`,~\/\*\,       -          \/\\,\*|`:||+_   |+/ *       -         *|";,`'\||,,|,=`/_//|'_       -         |||,_  "_||"/'|;-_"\|""`      -         \||-_ '_|"__|+++~~~=|"=`      -         '""_`|*\'\_____||*|;~-_       -           ,\*||/ |*""***^;///./       -           \,|^\\        ,\|/'~        -           '**||~+_    _/_|/ ,         -             \-\"~+|;=*`_+"_/          -               ~;=__,+/*_""            -                   ```                 \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_33.txt b/codex-rs/tui2/frames/default/frame_33.txt deleted file mode 100644 index dbd9568018a..00000000000 --- a/codex-rs/tui2/frames/default/frame_33.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -               _,+=+=+=~._             -            _+*||\*=~:|-|*|*.          -          _/\|+*+,="`  "*/||+",        -         ,\/|/_|,_        *||\_*       -        _.|/`||/\^\         |+\\^      -        //,   \_\\`\,        /\/_\     -        ||+ !  \,*|_|\       |*||      -        *|,,   ,''\/=,       \"|*      -        ||| | ,` /*,,,;==+~~+/_|\~     -        ^/|'"/:,/`~|_____||*^^^~|.     -        \=\, |/|/ / """"*** ||,/^`     -         \+\*,",           //;/=/      -          *\"*,*;,      _+|,/*\'       -            \~"*\+|,;+_";,\*~*         -              "==+,___^+*;-"           -                   ````                \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_34.txt b/codex-rs/tui2/frames/default/frame_34.txt deleted file mode 100644 index 7fc67a92dbc..00000000000 --- a/codex-rs/tui2/frames/default/frame_34.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -               _,=++++=~,_             -           _+*+/\;|"~+*=**;,=_         -         _//,|*":~*`   `^\\,"**,       -        ,/_|*_/,_          *|,*\\      -       //|\-/|!|"!,          \\*\^     -      ,/\|`/ \\"|\*\          \,\|\    -      |*^~_   '\_*|_^          |;~_    -      \|=":    |*_|/"|         / _\    -      ,'/."   /\//"_==++++++~__" ~^`   -      ||\,/,,^"//'~||_~.___,/_||`|\    -       /_\, |\|*^!  "**````^ ,/,\|'    -        /,\;=\_             /;.|/'     -         \+`|+\;,        _+="_/,`      -           -\/~"|+,;,+=*=*`+*:'        -             "~\;=_____;=*=^           -                   ```                 \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_35.txt b/codex-rs/tui2/frames/default/frame_35.txt deleted file mode 100644 index 570f34f0de5..00000000000 --- a/codex-rs/tui2/frames/default/frame_35.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -              _,+;=+++=+,_             -           ,*=*.~**+"|*~:"~*=_         -        _//|;+*|^*"`  `"*=*."|*,       -       ,//+/,;,_            *+_\|_     -      //~|//\ "\*,            |\*|,    -     ;/;/+` '|_"..*_           |\\|    -     \ !./    \\ '*_.           |^\\   -     ~|~:      /\\;/'           / "/   -     / ,_    ,||/;"/|==_;_=+~~  |~=*   -     |,^;'  //;"*+/|; \,____/"~/' |'   -      /`\,'/\_|/^`  `"^^^^*^^*//_,|    -       ".^\/~               _/* ,^     -        \;`^;,:,          ,;/_**'      -          "^_"~*~-:~~+~;/*"_/;"        -             "^~;=__/__++*"^           -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_36.txt b/codex-rs/tui2/frames/default/frame_36.txt deleted file mode 100644 index 74d83c8e702..00000000000 --- a/codex-rs/tui2/frames/default/frame_36.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -              __+=++++=+,_             -          _=""\+/;/+\+;++"**+_         -        ,\'\,+*-*"`` `"*~*+|,*|,       -      _|"*+____            '*~\"|      -     ,/_;\'|\`\,.             ^\.*     -     / ,/`  *_ "|/,            "\^*    -    | ;!`     !\ "\\            |^|,   -    ||\~      _\ _//!           \| |   -    |'"|     // ,*"',++_+++++_  |\~|   -     _*|\  ,|__/~/ !`~_______|| \/'`   -     ' *|\ +_+/^     "**^^^^^" |,"/    -      ',"\;.                 ,/|"/     -        \/||+~,           ,++"/,`      -          *,_"**=^;~_+~;"-",;+'        -            `*+/~_,,_,,++**"           -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_4.txt b/codex-rs/tui2/frames/default/frame_4.txt deleted file mode 100644 index 06dbce99c07..00000000000 --- a/codex-rs/tui2/frames/default/frame_4.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -             _.=;+==+=+,_              -         _-"+*|/!|\=/*;|"/*,_          -       ,*=|||+*"`   `^~\^*|/\\_        -      //|,|".+,          "|**\*\       -     /|/_|=|\;^|,          \"\|*\      -    /||^|  '_||/*\          ' \=|,     -    "\|;`   '**\+"|,         , ";|     -     |.\     ,|/*^||           |||     -    _|!|_   ;|/"^//+~+++____,, ||*     -    |\/!| ,///,_|`=\|,._,:/^;|//"|     -     `|;'\,\\,\/   "*******'^-|||`     -      ,|\"|_`             ,^;/**'      -       \\,:^!,_        _.^,*;|/        -         ~||=_**\;;=;,+=*+\|*`         -            *\\~:~_____;-*`            -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_5.txt b/codex-rs/tui2/frames/default/frame_5.txt deleted file mode 100644 index 6b1ce124479..00000000000 --- a/codex-rs/tui2/frames/default/frame_5.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -             _.=;;+===+,_              -         _+"+/*/||+~=+;_|"+,           -        *_/\+|*"`   "^=|*\!,*,         -      ,|/|/|.=,          -^||||_       -     ,\/*/^_+|_\,         ` \|\|_      -     ~|||/ \_|\;/|_          +/||      -    |/|!+   `\_|\"|,        ''~+|,     -    |/|_"    ,_=|/_|          +|_|     -    |,|^*   /_|",|*=_~+~___+,_"|.|     -     _\|\,,/+*" |"!//\=^,=.,/|/*|`     -     \,||,~,\\/+`  "**""""",~;|\/      -      \\+/\\ `            /_/*,^       -       ^/*\|=+_        _=+,*';*        -         ^|*|,*+\;~=_,+=*^~;*          -            ^-*_*"___-_,+*`            -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_6.txt b/codex-rs/tui2/frames/default/frame_6.txt deleted file mode 100644 index 7724f483dc6..00000000000 --- a/codex-rs/tui2/frames/default/frame_6.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -             _.=;;+==++__              -          ,^;/*|=*+|++;_,*+_           -        ,,,|||*"   `"~.=*+*/\_         -       /,|*|*_=,        \+||||,        -      '=|||*\\+*\         .\\|\,       -     ;-/|/`^+;|\_|,        .=\/|       -     _ +~|   !^\|/^\        ^+|_|      -     ~ |~|   _|=~/||        ~+\||      -     _.|-|  /'||*;/+_~+~__++_.\/|      -     ||\,/_^_;+ |/=*,||,;==\|,|\!      -      / | /~||;/"  "*"""'*"`\\/|       -       , ; /,`           ,:_//|`       -        .`\_**,       _+^_/*,+         -         `"~*,"+!;~__,+**!:;'          -            ^-;*+,___`_,+*             -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_7.txt b/codex-rs/tui2/frames/default/frame_7.txt deleted file mode 100644 index 0d0f43072c6..00000000000 --- a/codex-rs/tui2/frames/default/frame_7.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -             _.~;*+==+,_               -          ,*`+\|+*+==\;!`*,            -         * ,|||*`  `^~`!*/_*,          -        \\|||~;+       ^~\*|/,         -       `'|*||^|/\,       . *||\        -      | ;||" `'\;|\       ,-|||        -       `\"|  ^_|\|\|       **^||       -      _"/~|   =+/|*|`      ' |||       -       /"~* ,"_/|\/~~;;_~~=;*\||       -      |,'||=:~|/'|.\||\\,=,;\|\|       -       \ =^/*|*_/  ******""_/||        -       '_ /_/_`         ,"-/;/'        -        ';,+\\\_      ,^~,*/*'         -          \_'\|*\;~___+*|*+/           -            "~*"~:~__,_;**             -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_8.txt b/codex-rs/tui2/frames/default/frame_8.txt deleted file mode 100644 index 2e8019c0612..00000000000 --- a/codex-rs/tui2/frames/default/frame_8.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -             __+_;++=+,_               -           ,"*/|||*==!~ "+             -         _|=,|//*!,"~/~*\+^,           -        _*\*/|,+|     ' =^||\          -        ' /|/,\|/\      .'\||,         -       |',|\^^_\|_*      \+|||         -       |*||| '_;\|`|      ^|/|,        -       \,||/  |+\/|,*.    .`/||        -       \ \||_^ !/*=|~/+,+,,\~||        -       !  \*/=_|",|,||;|=__||='        -        //\\|\|||/'^*^*"\*"/\|         -        ',"|\|``        .,///'         -         '\_*\\\_    _/\_|+/*          -           .:'|,*!;;+*;/=,|`           -             ~~_**\|_,+/=`             -                  ``                   \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_9.txt b/codex-rs/tui2/frames/default/frame_9.txt deleted file mode 100644 index 128e9150078..00000000000 --- a/codex-rs/tui2/frames/default/frame_9.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -              .=^*/++=,                -            /*_/||*"=!_-\_             -           / ,||/*^^=/!\_~,            -          " ||/;=_ _^,|\^|+,           -         /*";\*"|*,  +:+||           -         | |||"^|\;*   '|*|||          -         ` ~*\ **|\\," / `||          -        |  ~/_ ~||_/= | !||          -        !  ",|" /|"|~~|+|~,||`         -         |_|||_,|^|_||||__|||          -         " `^/\\|/"****||"/\|          -          \~||\,`     ,/ ^/|`          -           \+\|\\    /;_;|/*           -            ^-"\_|*/+=;;*|`            -             '-_ *\\|;+/"              -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_1.txt b/codex-rs/tui2/frames/dots/frame_1.txt deleted file mode 100644 index 36964a48647..00000000000 --- a/codex-rs/tui2/frames/dots/frame_1.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ○◉○◉○●●○○●●○ - ○○●◉●○●◉●○○··○○ ○ ●○ - ●·●·●●● ○· · ●· ·○···● - ◉●○○●●●●○ ◉●·◉·● - ○○◉◉●○·○·○○ ◉·○○● - ·● ●· ·●◉◉··● ●◉○··● - ●○ ◉● ●○·●◉ ·○ ·◉· - ··○·· ●◉◉·◉·● ·· - ·○·●· ◉··○○· ◉·●●●●●●○●● ○ · - ○·◉● ○◉●· ◉● · ·○○○◉◉●●●·◉●◉●· - ○○○ ○ ○○●◉◉· ·○●●●● ○● ◉●◉○◉ - ●○● ● · ●●◉●○· - ○○●·○●○ ○○○●·○● - ○●●○○ ●●◉◉○◉◉◉○●●○●·● - ·● ●···○○○●○◉●● - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_10.txt b/codex-rs/tui2/frames/dots/frame_10.txt deleted file mode 100644 index 3c687d7f64f..00000000000 --- a/codex-rs/tui2/frames/dots/frame_10.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ○●●●●○●●○ - ●●·●●●○·◉○●● - ○○●··◉··◉·●○●● - ·○○◉○·◉○◉○●●●○○● - ◉ ◉·· ·○ ●●◉○◉·◉ - ·· ●·●·◉◉○ ·○ · - ○ ·● ●····● ·◉● - ··○●◉●··◉○·◉○·· - ·○ ○○·◉··●○●◉·· - · ●·○●·○◉○●○○○·●· - ● ····· ○● ○·· - ○·●●○●● ○· ●◉◉ - ·● ····●●◉●◉·◉· - ◉·●●◉·●◉○ ◉◉● - ●● ○●○○●◉● - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_11.txt b/codex-rs/tui2/frames/dots/frame_11.txt deleted file mode 100644 index c2548db4b3c..00000000000 --- a/codex-rs/tui2/frames/dots/frame_11.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ●●●●●●●○ - ◉ ◉···○○● - ◉ ○○··◉○●◉○● - ·○○··○◉●○○◉◉ - ○ ◉◉○·●○●◉○··● - ·●···●○◉◉·○◉·· - ●● ●······○·○○ - · ··◉··○◉ · · - ◉ ●·◉··●◉●·○· - ●● ○·◉○● ◉·○·○ - ●·◉ ····○◉·○○· - ○○◉◉·○●◉●●◉·· - ○ ●·○○○○·◉ - ○● ●··●○◉●· - ○○ ●●●○●· - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_12.txt b/codex-rs/tui2/frames/dots/frame_12.txt deleted file mode 100644 index 30b03392bf4..00000000000 --- a/codex-rs/tui2/frames/dots/frame_12.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ●●●●●◉ - ●○·○◉·●○ - ·◉○●● - ◉●···○○ · - · ●····◉◉· - ·●◉◉●○ ○· - · ·◉○··○ - · ◉●○·· - ●● ·◉··●·· - · ◉·●◉●○ - ·◉ · ○●●◉ - ● ●· ○●· - ◉ ●●·●●○· - ●○◉ ●·○·· - ○ ●·○◉◉ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_13.txt b/codex-rs/tui2/frames/dots/frame_13.txt deleted file mode 100644 index cb95f3763d3..00000000000 --- a/codex-rs/tui2/frames/dots/frame_13.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◉●●●● - ·○○·· - ··○· - ·●●·○ - ● ·· - ◉◉·· - ● ·· - ● · - ◉● ○·◉ - · ●○· - ●·◉◉ ·· - ·○○○· - ·●●·○ - ●○○·○ - ● ◉ ○ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_14.txt b/codex-rs/tui2/frames/dots/frame_14.txt deleted file mode 100644 index 3a8ed60b8ff..00000000000 --- a/codex-rs/tui2/frames/dots/frame_14.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ●●●●◉ - ●◉○○◉○ - ·○··◉ · - ··◉· ○·● - ···○ · - ·●·· ●· - ···· ◉ ● - ··○·· ○ - ···· - ○◉◉ ◉◉ - ○· ●◉●· - ·●··○○○· - ◉ ·○○○· - ·○◉●○○◉ - ○● ●○◉ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_15.txt b/codex-rs/tui2/frames/dots/frame_15.txt deleted file mode 100644 index c57b4af0ee5..00000000000 --- a/codex-rs/tui2/frames/dots/frame_15.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ●●●●●·○ - ·●○··○◉●● - ◉◉·●○○· ●· - ·◉· ◉◉●◉○○· - ··○ ○○··◉ ○· - ○··◉··◉◉· - ·· ······●· - ··◉··●··· ●◉· - ··●●●●···○◉◉· - ·○·◉◉·○·●◉ ◉· - ·◉····○· · ◉ - ·●●◉○··○◉ ○ - ●·○● ◉◉◉· ◉· - ·○··○○◉●○◉ - ○○●○○○○◉ - ·· \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_16.txt b/codex-rs/tui2/frames/dots/frame_16.txt deleted file mode 100644 index 18ae0e09ee3..00000000000 --- a/codex-rs/tui2/frames/dots/frame_16.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ○○● ●●●·○ - ◉○··●··○·○○ - ·◉◉ ○◉○○·· ●○ - ◉◉◉ ◉●○○·○··●●· - ··◉○◉●●○··●◉··○ - ···●·● ·○○ · ·◉· - ○○· ◉·●····◉● ● · - ·◉◉ ····◉··· ◉ ·· - ○○···●·◉○·· ●● ◉ - ● ○○●○○○●·◉○· ●○· - ···● ◉●○·◉ ◉◉··· - ··● ·· ·◉◉● ◉ - ○·● · ◉●◉○●· - ○·· ○●·◉◉●○· - ●◉◉○○◉○○◉● - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_17.txt b/codex-rs/tui2/frames/dots/frame_17.txt deleted file mode 100644 index a470b4ba8df..00000000000 --- a/codex-rs/tui2/frames/dots/frame_17.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ●○●●●◉◉·●○ - ○◉●●···●○●·● ○● - ●●○◉○○○·●● ···○ ○ - ◉·◉○◉· ○· ●·◉○···○○ - ·◉○ · · ◉○○○◉◉ ● - ·○●●◉· ●◉◉· ◉●○◉·◉◉ - ● ◉◉· ●◉◉·○ ● ··◉○· - ◉ ○· ◉◉··◉·●··○◉● - ●◉●◉○○○●·○○·○○◉○··· · - · ○◉○○·◉●○◉··○ ○●●●○ - ○○○◉ ●●●●○○·●◉◉○ ○● - ○··○● ·◉··· ◉ - ○○○ ●○○ ●●●◉○○◉ - ● ·●●○○●·○◉●◉·● - ·●◉◉○●●·●○●● - · \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_18.txt b/codex-rs/tui2/frames/dots/frame_18.txt deleted file mode 100644 index c0354b39331..00000000000 --- a/codex-rs/tui2/frames/dots/frame_18.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ○○○●○○●◉·●○ - ○● ○◉●●●·○○● ●◉◉○ - ○◉◉·● ●○ ● ◉○ ●◉◉○ ● - ●··●◉●○ ● ●○·○◉○·○◉○○○● - ○○◉◉ ◉· ○ ●○○◉·◉·○○◉○○○ - ◉○· ●●·◉○○○◉●●●·●◉◉○◉·◉· - ··○◉◉ ●◉●◉· ○◉ ●·○·○● - ○◉·· ○○ ··◉○ ·◉· - ○ ●○○○◉○◉●··●·●◉○● ·●·· - ○○○·●○● ··◉○○○○···○◉··○ ● - ·◉·○· ●●●●●· ○○○◉·◉●◉·◉· - ○○ ●○● ··● ◉●○● - ○○●●○○◉ ○●○○◉○●· - ●·◉●●●●○○○○●●○◉○●● - ··○◉○○●◉● ◉● - · \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_19.txt b/codex-rs/tui2/frames/dots/frame_19.txt deleted file mode 100644 index c9ded568388..00000000000 --- a/codex-rs/tui2/frames/dots/frame_19.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ○○○○●·●○○·◉○ - ○●···○◉○○○○●○●·●●● - ●●◉◉◉ ·●●· ·●◉●·●◉○ - ◉◉·●●○· ○○○●●○·●● - ··◉·◉· ●·○ ◉ ·○ ·○● - ·○◉●● ○·●·◉ ◉◉ ●◉●· - ○○··● ◉●○◉·●●· ◉◉◉●· - ···· ·◉ ○· ● · ··· - ·●·○●○◉◉○○○····●●◉●·◉ ○ ·· - ○◉◉●·○●◉○●●○●·● ○○ ◉○○○●◉◉·○ - ●○ ◉●· ●● ○○○◉·◉◉ ◉◉● - ○○○●○○ ·◉◉○◉◉● - ● ○○··●● ●◉○◉◉·◉· - ● ◉·●◉·●○○○◉◉● ◉●·● - ·◉○◉◉●○●◉○●◉○ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_2.txt b/codex-rs/tui2/frames/dots/frame_2.txt deleted file mode 100644 index 6e7a27fb294..00000000000 --- a/codex-rs/tui2/frames/dots/frame_2.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ○◉○◉○●○○○●●○ - ○●●◉●◉●···○··○○●·●●○ - ●·●·○●●·●·· · ●○●◉··◉·● - ○·◉○◉●●●●○ ◉●○·◉·● - ●○ ◉●○·○○○○○ ○●·○·● - ●◉● · ·●○●◉○● ○○·●· - ●·●●· ○◉·◉·○·● ◉ · · - · ○· ●·●··◉● ·●◉◉· - ● ○· ◉·○○○· ◉·●●●●●●●●●·○··● - ○○●· ◉··● ◉● ·○○○○○○◉◉○○·●◉ · - ○○○ ● ●○◉◉·● · ●●●●●●● ·●●·· - ●○○◉◉● · ●●◉◉●● - ◉○○○·●● ●○○● ○· - ··○◉○○ ●●◉◉○◉◉◉○●·○··● - ·●●●●··○○○○·◉◉● - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_20.txt b/codex-rs/tui2/frames/dots/frame_20.txt deleted file mode 100644 index d9809e733cc..00000000000 --- a/codex-rs/tui2/frames/dots/frame_20.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ○○●○··○●○●○○ - ●◉○ ◉●○●○○○··○○●○●● - ●● ○●● ·● · ○ ○●●·●○ - ○· ○●·● ○●●·○◉●○○ - ○· ●·● ○◉ ○··○●○··○ - ◉·●·● ●●●◉· ◉ ●○··○ - ◉·◉●· ●● ◉●●○ ●○·◉ - ·○○◉· ◉◉· ● ·· · - ·◉··◉●○◉○○○○○○○●●◉○○○○● ◉ ·○ - ●·○·○ ◉··○○○○·○ ●○○ ·○○ ●◉ ·· - ○●○○● ● ○●○··●· ◉○ - ● ○●◉● ○◉●◉·◉ - ○○○●●·○ ●◉●·◉●● - ○●◉◉ ·●○◉◉●◉ · ·●◉●● - ·●●·○ ○○○○○·● - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_21.txt b/codex-rs/tui2/frames/dots/frame_21.txt deleted file mode 100644 index 0821f12d752..00000000000 --- a/codex-rs/tui2/frames/dots/frame_21.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ○○●○●○○●○●●○○ - ●●●··○○●·○·●◉○·◉●●○ - ◉●○○●●○ · ·○·●●○·●● - ●●·◉ ● ○●◉●●◉●● - ◉●◉●· ●·◉○○ ○○○○● - ◉ ◉· ●●●○◉◉ ○○○● - ·●·· ○ ◉·●◉● ○◉○ - ·○ ○◉ ·● · · - · · ●●◉◉◉◉◉●●● ○·● ·● ◉◉ · - · ·○ ·○ ○●· ○ ·●·● ◉ ◉◉ - ◉ ◉ ·· ●· ●●○●◉ ◉· ● - ●● ◉◉○ ○● ◉ - ●○ ○◉○ ○◉◉●● ●· - ●○○ ● ●●◉··◉◉○● ◉· - ●●●◉●○○○○●○◉●●· - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_22.txt b/codex-rs/tui2/frames/dots/frame_22.txt deleted file mode 100644 index d6733498019..00000000000 --- a/codex-rs/tui2/frames/dots/frame_22.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ○●●○●○○●○●●○ - ○●◉●·◉○◉·◉○··◉·●◉●●○ - ●◉●◉·○●●· · ●○●●●○○●● - ◉●·●● ●●···○●○ - ○··◉● ●○◉·○·◉◉◉●· - ○·●◉· ●·◉·◉○◉· ○·○○· - ·●●○◉ ·●·○●◉● ··◉· - ··○·· ·○··○○ ·○· · - ··● ● ●·○○◉◉◉○●●○○●·○ ○● ○·◉ - ○·○ ●○○○● ◉● ·◉◉· ○●◉○ ·●● ◉○○ - ○○○○· ●● · ·◉●●○◉◉◉ ●· - ○●○○○● ○●● ·●● - ● ●◉ ●●○ ○◉●● ●○◉ - ●◉●●● ●·◉○●◉◉◉○● ●●● - · ·○◉○○○◉○○◉○●· - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_23.txt b/codex-rs/tui2/frames/dots/frame_23.txt deleted file mode 100644 index 180ab167842..00000000000 --- a/codex-rs/tui2/frames/dots/frame_23.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ○●●○○●●●●○●○○ - ○○·●·○○●○○○●·○·●·●○ - ● ●·●●◉●· ·◉●·○◉··● - ◉◉·●◉◉· ○◉◉○··◉○ - ◉◉··◉● ●◉●●●●◉●○·○ - ●○· ◉· ○●●●●●◉●●··○·● - ··· · ◉◉◉◉ ○·· ·●● - ·◉· · · ·●○○● · ●◉· - ○ ○ ·●·◉○○○○○◉○·○●·○ ○○ · ··· - ·○○○· ○ ◉·○ ○·○○●· ○○· - ◉●· · · ●·◉◉○·○◉○◉· - ●··○·◉ ●◉·○○◉· - ○ ·●●◉○○ ○◉ ◉●◉●◉ - ○○○●◉●●○·○○○●● ◉○●●· - · ●○○·○○●○○●●· - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_24.txt b/codex-rs/tui2/frames/dots/frame_24.txt deleted file mode 100644 index 3244b1c6f92..00000000000 --- a/codex-rs/tui2/frames/dots/frame_24.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ○·○◉●●●○○●○ - ● ○◉ ·○○·○○●●○●● - ● ●◉○◉● · ◉·◉●·●●● ○ - ●●◉◉●◉·○●○··◉· ○●○·○○● - ● ◉◉○◉● · ◉○○···○·●● - ·●○●○· ●●●◉ ◉·◉ ◉ ○ - · ·· ●◉○●◉ ◉○○·○○·○ - · ○·· ·●○◉○○··○◉◉··· - ·◉ ·◉◉◉◉●●···●●○●●○· · ·· - ○ ◉ ●·◉●●◉◉●●·◉○○●●· ·●· - ○ ●○·○● · ○●◉●○·○◉●· - ○ ○●●○ · ◉◉◉ - ○◉●○··◉ ○○ ○◉◉○● - ◉◉●●○○ ··◉○○◉·○○◉· - ●◉○●●◉○○○○○●● - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_25.txt b/codex-rs/tui2/frames/dots/frame_25.txt deleted file mode 100644 index c04ef18b74f..00000000000 --- a/codex-rs/tui2/frames/dots/frame_25.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ○●○●◉●●●●○ - ● ●●●·○◉○·●··● - ◉·◉◉●● ◉·●○○○···○ - ·●●· ◉ ○· ○·●●○◉ - ●○○○●◉●○◉◉○· ·◉◉○◉● - ○●···○●●◉○·◉ ◉·●·◉◉·● - ·◉·●· ·○○◉○○◉·○◉○ ·○· - ···◉·◉ ·○○○◉·○○·○··○ - ○●○· ·○○○○○○●··○○·●●·○ - ◉◉ ·◉·●·○··◉●○·○●○◉○· - ○·●◉○·· ●●◉·○·◉·· - ·○ ·◉●◉○◉●●◉○◉●◉◉·◉ - ○·○·○○○●◉◉●○◉○··◉ - ○◉◉○○○●○·●● ●·● - ·● ●○●●○●·● - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_26.txt b/codex-rs/tui2/frames/dots/frame_26.txt deleted file mode 100644 index 1ecc43beef2..00000000000 --- a/codex-rs/tui2/frames/dots/frame_26.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ○◉●●● ●● - ◉●·◉◉○·●◉·● - ◉● ·◉○·○ ○● - ◉● ●···○··○●◉○ - ·○●○·◉· ●○◉◉·○◉ - ○ ·◉◉◉ ●○○ ○○◉ - · ●●○··◉··◉◉ ● - ◉◉ ○···○○○··◉·· - · ◉○●◉●·●○○◉○·· - ◉○·◉●·○○●●○○●· - ·· ◉·● ○●◉·●○·· - ○○··◉○ ◉◉ ··· - ● ●·○ ○·◉·◉● - · ●○○ ●○●●◉ - ○◉◉○●·○○● - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_27.txt b/codex-rs/tui2/frames/dots/frame_27.txt deleted file mode 100644 index 83e62da52e2..00000000000 --- a/codex-rs/tui2/frames/dots/frame_27.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◉●●●●● - ·◉◉◉·◉○ - ◉●· ●·◉●· - ···○··◉·· - · ··○◉●○ - ·○◉○ ····· - ○ ··○●·· - · ····○ ·· - ·◉ ◉ ·●···· - ●○ ◉ ○·○●·· - ·◉ ○○●●●◉● - ··●●··○◉· - ·○○●○●◉·○ - ○○○·○○◉◉ - ●○ ○○◉ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_28.txt b/codex-rs/tui2/frames/dots/frame_28.txt deleted file mode 100644 index 6d460c936de..00000000000 --- a/codex-rs/tui2/frames/dots/frame_28.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◉●●◉ - ·●○·· - ○ ◉·· - ·◉·●· - · ·· - · ◉ · - ○·· ○· - ·◉ · - ◉○ ○· - ··◉◉·· - · ○· - ·○●○· - ·○●·· - ●○○◉· - · ◉●· - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_29.txt b/codex-rs/tui2/frames/dots/frame_29.txt deleted file mode 100644 index d0d6b3c286d..00000000000 --- a/codex-rs/tui2/frames/dots/frame_29.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ●●●●●● - ●·●◉ ●○ - ○●·· ● · - ····○○●◉ - ○ · · · - ·●··· ○○ - ·○○··· ○ - ··○·· - ···●· · - ○·○·· · - ●··◉·○ ○ - ·◉··· ○· - ······○· - ·○·●◉ ·● - ··● ◉● - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_3.txt b/codex-rs/tui2/frames/dots/frame_3.txt deleted file mode 100644 index 062da3ed89f..00000000000 --- a/codex-rs/tui2/frames/dots/frame_3.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ○◉○◉●●○○○○●○ - ○●●◉○··●· ○○·◉○○·●●○ - ●◉◉●◉·● · · · ●●●◉○·● - ◉●◉○·◉●●○ ·○● ··○ - ●◉●·◉○◉··●○○ ○○○··○ - ◉·○○◉ ◉●·○◉○○ · ○○· - ○● · ●●○·○ ·● ◉●◉·● - ●◉·●· ●◉· ◉○· ·○·○· - ◉ ●· ◉·●●··●◉·○●●○○○●●○·○ ◉· - ●○◉○● ◉◉●◉◉◉· ·●·○○● ○◉●◉○··○·· - ●○· · ··●● ●●● ●●●◉◉◉●◉ - ●●· ○● · ◉○●○◉◉ - ○○◉◉●○● ●●◉◉ ●·● - ○○●●○●●○○◉○◉●◉○●◉·○● - ·●●●○◉·○○○○·◉◉○ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_30.txt b/codex-rs/tui2/frames/dots/frame_30.txt deleted file mode 100644 index 4bf02ade3d8..00000000000 --- a/codex-rs/tui2/frames/dots/frame_30.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ○◉ ●●●● - ○◉◉··○●●○○ - ●○·●○○·●●○ - ··○·◉·○○·○ ○● - ○●···○·●·● · - ·●·○● ●····◉· - ○◉···○○ ○◉○· ○· - · ●●○·●· ·◉ · - ·○ ○·○·○◉·● · - ··○·○○·· ●● · - ○··◉○○●···●·· - ·●◉◉●◉●○·· · - ○·○··◉●··●·◉ - ●○●●○○◉·●◉● - ○◉◉·○○○◉● - ···· \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_31.txt b/codex-rs/tui2/frames/dots/frame_31.txt deleted file mode 100644 index 99385ee51fa..00000000000 --- a/codex-rs/tui2/frames/dots/frame_31.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ○◉◉●●●●○● - ○●●◉●●○ ●○●●● - ●·○◉◉●○○·◉ ·○●○ - ●·◉●○○●◉○●○○·○··○ - ○····●◉●○○● ○·◉○○ - ◉·○··○·● ○◉●●◉◉·● · - ◉·· ·○●·○◉○◉◉●·●·◉· - ○○● ·· ·○ ·○ ··○◉· - ·○●○·●○···◉●···○ · - ···◉◉··○○○○○·○·· ● - ○●○◉●·● ◉●●●·◉·●·○ - ···○○○● ○○● · - ◉·●○○ ● ◉ ·●·◉· - ◉·○·◉○··●· ●·· - ● ○·○●●◉◉◉● - ···· \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_32.txt b/codex-rs/tui2/frames/dots/frame_32.txt deleted file mode 100644 index 771e9c9106b..00000000000 --- a/codex-rs/tui2/frames/dots/frame_32.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ●●●●●◉◉·●○ - ◉●·●●·○··●●··● - ◉○●○○●●○◉◉·●·●○●●● - ●◉◉·○●·◉◉ ◉ ●○○·●● ● - ◉◉·●·●○●●·◉ ●·●·○◉○●○● - ○◉○○●○●··◉··●○ ·●◉ ● - ●· ◉●·●○··●●·●○·◉○◉◉·●○ - ···●○ ○·· ◉●·◉◉○ ○· · - ○··◉○ ●○· ○○·●●●···○· ○· - ● ○··●○●○○○○○○··●·◉·◉○ - ●○●··◉ ·● ●●●○◉◉◉◉◉◉ - ○●·○○○ ●○·◉●· - ●●●···●○ ○◉○·◉ ● - ○◉○ ·●·◉○●·○● ○◉ - ·◉○○○●●◉●○ - ··· \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_33.txt b/codex-rs/tui2/frames/dots/frame_33.txt deleted file mode 100644 index 4d36c1eb6f2..00000000000 --- a/codex-rs/tui2/frames/dots/frame_33.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ○●●○●○●○·◉○ - ○●●··○●○·◉·◉·●·●◉ - ○◉○·●●●●○ · ●◉··● ● - ●○◉·◉○·●○ ●··○○● - ○◉·◉···◉○○○ ·●○○○ - ◉◉● ○○○○·○● ◉○◉○○ - ··● ○●●·○·○ ·●·· - ●·●● ●●●○◉○● ○ ·● - ··· · ●· ◉●●●●◉○○●··●◉○·○· - ○◉·● ◉◉●◉···○○○○○··●○○○··◉ - ○○○● ·◉·◉ ◉ ●●● ··●◉○· - ○●○●● ● ◉◉◉◉○◉ - ●○ ●●●◉● ○●·●◉●○● - ○· ●○●·●◉●○ ◉●○●·● - ○○●●○○○○●●◉◉ - ···· \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_34.txt b/codex-rs/tui2/frames/dots/frame_34.txt deleted file mode 100644 index 4cbd99c1435..00000000000 --- a/codex-rs/tui2/frames/dots/frame_34.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ○●○●●●●○·●○ - ○●●●◉○◉· ·●●○●●◉●○○ - ○◉◉●·● ◉·●· ·○○○● ●●● - ●◉○·●○◉●○ ●·●●○○ - ◉◉·○◉◉· · ● ○○●○○ - ●◉○··◉ ○○ ·○●○ ○●○·○ - ·●○·○ ●○○●·○○ ·◉·○ - ○·○ ◉ ·●○·◉ · ◉ ○○ - ●●◉◉ ◉○◉◉ ○○○●●●●●●·○○ ·○· - ··○●◉●●○ ◉◉●···○·◉○○○●◉○····○ - ◉○○● ·○·●○ ●●····○ ●◉●○·● - ◉●○◉○○○ ◉◉◉·◉● - ○●··●○◉● ○●○ ○◉●· - ◉○◉· ·●●◉●●○●○●·●●◉● - ·○◉○○○○○○◉○●○○ - ··· \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_35.txt b/codex-rs/tui2/frames/dots/frame_35.txt deleted file mode 100644 index 5ccdf711b5b..00000000000 --- a/codex-rs/tui2/frames/dots/frame_35.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ○●●◉○●●●○●●○ - ●●○●◉·●●● ·●·◉ ·●○○ - ○◉◉·◉●●·○● · · ●○●◉ ·●● - ●◉◉●◉●◉●○ ●●○○·○ - ◉◉··◉◉○ ○●● ·○●·● - ◉◉◉◉●· ●·○ ◉◉●○ ·○○· - ○ ◉◉ ○○ ●●○◉ ·○○○ - ···◉ ◉○○◉◉● ◉ ◉ - ◉ ●○ ●··◉◉ ◉·○○○◉○○●·· ··○● - ·●○◉● ◉◉◉ ●●◉·◉ ○●○○○○◉ ·◉● ·● - ◉·○●●◉○○·◉○· · ○○○○●○○●◉◉○●· - ◉○○◉· ○◉● ●○ - ○◉·○◉●◉● ●◉◉○●●● - ○○ ·●·◉◉··●·◉◉● ○◉◉ - ○·◉○○○◉○○●●● ○ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_36.txt b/codex-rs/tui2/frames/dots/frame_36.txt deleted file mode 100644 index 6a26abaea68..00000000000 --- a/codex-rs/tui2/frames/dots/frame_36.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ○○●○●●●●○●●○ - ○○ ○●◉◉◉●○●◉●● ●●●○ - ●○●○●●●◉● ·· · ●·●●·●●·● - ○· ●●○○○○ ●●·○ · - ●◉○◉○●·○·○●◉ ○○◉● - ◉ ●◉· ●○ ·◉● ○○● - · ◉ · ○ ○○ ·○·● - ··○· ○○ ○◉◉ ○· · - ·● · ◉◉ ●● ●●●●○●●●●●○ ·○·· - ○●·○ ●·○○◉·◉ ··○○○○○○○·· ○◉●· - ● ●·○ ●○●◉○ ●●○○○○○ ·● ◉ - ●● ○◉◉ ●◉· ◉ - ○◉··●·● ●●● ◉●· - ●●○ ●●○○◉·○●·◉ ◉ ●◉●● - ·●●◉·○●●○●●●●●● - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_4.txt b/codex-rs/tui2/frames/dots/frame_4.txt deleted file mode 100644 index b4496013b5e..00000000000 --- a/codex-rs/tui2/frames/dots/frame_4.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ○◉○◉●○○●○●●○ - ○◉ ●●·◉ ·○○◉●◉· ◉●●○ - ●●○···●● · ·○·○○●·◉○○○ - ◉◉·●· ◉●● ·●●○●○ - ◉·◉○·○·○◉○·● ○ ○·●○ - ◉··○· ●○··◉●○ ● ○○·● - ○·◉· ●●●○● ·● ● ◉· - ·◉○ ●·◉●○·· ··· - ○· ·○ ◉·◉ ○◉◉●·●●●○○○○●● ··● - ·○◉ · ●◉◉◉●○··○○·●◉○●◉◉○◉·◉◉ · - ··◉●○●○○●○◉ ●●●●●●●●○◉···· - ●·○ ·○· ●○◉◉●●● - ○○●◉○ ●○ ○◉○●●◉·◉ - ···○○●●○◉◉○◉●●○●●○·●· - ●○○·◉·○○○○○◉◉●· - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_5.txt b/codex-rs/tui2/frames/dots/frame_5.txt deleted file mode 100644 index 0905c495b26..00000000000 --- a/codex-rs/tui2/frames/dots/frame_5.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ○◉○◉◉●○○○●●○ - ○● ●◉●◉··●·○●◉○· ●● - ●○◉○●·● · ○○·●○ ●●● - ●·◉·◉·◉○● ◉○····○ - ●○◉●◉○○●·○○● · ○·○·○ - ····◉ ○○·○◉◉·○ ●◉·· - ·◉· ● ·○○·○ ·● ●●·●·● - ·◉·○ ●○○·◉○· ●·○· - ·●·○● ◉○· ●·●○○·●·○○○●●○ ·◉· - ○○·○●●◉●● · ◉◉○○○●○◉●◉·◉●·· - ○●··●·●○○◉●· ●● ●·◉·○◉ - ○○●◉○○ · ◉○◉●●○ - ○◉●○·○●○ ○○●●●●◉● - ○·●·●●●○◉·○○●●○●○·◉● - ○◉●○● ○○○◉○●●●· - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_6.txt b/codex-rs/tui2/frames/dots/frame_6.txt deleted file mode 100644 index 3f96b667617..00000000000 --- a/codex-rs/tui2/frames/dots/frame_6.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ○◉○◉◉●○○●●○○ - ●○◉◉●·○●●·●●◉○●●●○ - ●●●···● · ·◉○●●●◉○○ - ◉●·●·●○○● ○●····● - ●○···●○○●●○ ◉○○·○● - ◉◉◉·◉·○●◉·○○·● ◉○○◉· - ○ ●·· ○○·◉○○ ○●·○· - · ··· ○·○·◉·· ·●○·· - ○◉·◉· ◉●··●◉◉●○·●·○○●●○◉○◉· - ··○●◉○○○◉● ·◉○●●··●◉○○○·●·○ - ◉ · ◉···◉◉ ● ●● ·○○◉· - ● ◉ ◉●· ●◉○◉◉·· - ◉·○○●●● ○●○○◉●●● - · ·●● ● ◉·○○●●●● ◉◉● - ○◉◉●●●○○○·○●●● - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_7.txt b/codex-rs/tui2/frames/dots/frame_7.txt deleted file mode 100644 index aa52e1b869d..00000000000 --- a/codex-rs/tui2/frames/dots/frame_7.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ○◉·◉●●○○●●○ - ●●·●○·●●●○○○◉ ·●● - ● ●···●· ·○·· ●◉○●● - ○○····◉● ○·○●·◉● - ·●·●··○·◉○● ◉ ●··○ - · ◉·· ·●○◉·○ ●◉··· - ·○ · ○○·○·○· ●●○·· - ○ ◉·· ○●◉·●·· ● ··· - ◉ ·● ● ○◉·○◉··◉◉○··○◉●○·· - ·●●··○◉··◉●·◉○··○○●○●◉○·○· - ○ ○○◉●·●○◉ ●●●●●● ○◉·· - ●○ ◉○◉○· ● ◉◉◉◉● - ●◉●●○○○○ ●○·●●◉●● - ○○●○·●○◉·○○○●●·●●◉ - ·● ·◉·○○●○◉●● - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_8.txt b/codex-rs/tui2/frames/dots/frame_8.txt deleted file mode 100644 index 5791ce70e48..00000000000 --- a/codex-rs/tui2/frames/dots/frame_8.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ○○●○◉●●○●●○ - ● ●◉···●○○ · ● - ○·○●·◉◉● ● ·◉·●○●○● - ○●○●◉·●●· ● ○○··○ - ● ◉·◉●○·◉○ ◉●○··● - ·●●·○○○○○·○● ○●··· - ·●··· ●○◉○··· ·○·◉·● - ○●··◉ ·●○◉·●●◉ ◉·◉·· - ○ ○··○○ ◉●○··◉●●●●●○··· - ○●◉○○· ●·●··◉·○○○··○● - ◉◉○○·○···◉●○●○● ○● ◉○· - ●● ·○··· ◉●◉◉◉● - ●○○●○○○○ ○◉○○·●◉● - ◉◉●·●● ◉◉●●◉◉○●·· - ··○●●○·○●●◉○· - ·· \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_9.txt b/codex-rs/tui2/frames/dots/frame_9.txt deleted file mode 100644 index 35588ee1ee7..00000000000 --- a/codex-rs/tui2/frames/dots/frame_9.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◉○○●◉●●○● - ◉●○◉··● ○ ○◉○○ - ◉ ●··◉●○○○◉ ○○·● - ··◉◉○○ ○○●·○○·●● - ◉● ◉○● ·●● ●◉●●◉●·· - · ··· ○·○◉○○ ·●·●··· - · ·●○·●●·○○● ◉●◉ ··· - · ·◉○●○···○◉○○○· ·· - ●· ◉· ····●··●··· - ·○···○●·○·○····○○··· - ·○◉○○·◉ ●●●●·· ◉○· - ○···○●· ●◉ ○◉·· - ○●○·○○ ◉◉○◉·◉● - ○◉ ○○·●◉●○◉◉●·· - ●◉○ ●○○·◉●◉ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_1.txt b/codex-rs/tui2/frames/hash/frame_1.txt deleted file mode 100644 index 45adbbac247..00000000000 --- a/codex-rs/tui2/frames/hash/frame_1.txt +++ /dev/null @@ -1,17 +0,0 @@ - - -.-A*##**##- - -*#A**#A#**..*- -█#- - #.*.#**█-- -█*-█.*...# - **-**█##- A*.*.# - *-*A█-.*-** █..**# - .* #- .*A*..# █.*..# - #-█-* █*.*A█.- .A. - ..-.- #AA.*.* █-. - .*.█- *..-*.█..######-## *█ . - -.** -*#- A* .█.---.A###.A#A#. - *--█- -*#.A- --*██* -*█A#*-A - *-# █# - #█A*-. - -*#-*#- -*-#.-#█ - -*#*- *#A****.**#-#.* - -*█*...---#-*#*█ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_10.txt b/codex-rs/tui2/frames/hash/frame_10.txt deleted file mode 100644 index 0e9a76d4d8f..00000000000 --- a/codex-rs/tui2/frames/hash/frame_10.txt +++ /dev/null @@ -1,17 +0,0 @@ - - -#****##- - *█-#*#*.A-*# - --#..A..-.#*## - .--A*.*-.*#██**# - A *..█.- █#A-A.A - .- █.*.AA* --█. - * .*█*....* .A# - █ ..*#A#..---.*.. - █ .* **.*..#*#*.. - . #.*#.-A-*---.*- - # █.....██ *#█*.- - *-█#*#*█ -.██#AA - .█ ....*#A#A.A- - *-**A.#*- AA█ - *# -**-#** - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_11.txt b/codex-rs/tui2/frames/hash/frame_11.txt deleted file mode 100644 index b7e743b218b..00000000000 --- a/codex-rs/tui2/frames/hash/frame_11.txt +++ /dev/null @@ -1,17 +0,0 @@ - - #****##- - A█ *...**# - A█--..***A*# - .--..**#-*AA - -█.**.#*█*-..# - .#-..#-**.-A.. - ** #......-.** - . ..A..*A .█. - A █.A..*A#.-. - ** -.A*#█A.-.- - █-- ....*A.**. - *--A.-*A***.- - - *.**--.*█ - *# *..#-A*- - *- █*#-#- - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_12.txt b/codex-rs/tui2/frames/hash/frame_12.txt deleted file mode 100644 index 0c6c85043f9..00000000000 --- a/codex-rs/tui2/frames/hash/frame_12.txt +++ /dev/null @@ -1,17 +0,0 @@ - - #***#. - #*--A.#* - █ .A*## - A#...**█. - . █.....A. - .█..*-█-.█ - . .A*..* - . A#*..█ - *# .A..#.. - . A.***- - .. .█***A - # *. -#. - A **.#**. - █-A█#.*.- - * █.*.A - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_13.txt b/codex-rs/tui2/frames/hash/frame_13.txt deleted file mode 100644 index 097cd508d7e..00000000000 --- a/codex-rs/tui2/frames/hash/frame_13.txt +++ /dev/null @@ -1,17 +0,0 @@ - - A***# - .--.. - .--.█ - .**.- - * .. - █A-.. - # .. - # █. - A# -.. - .█ #*. - █-.. .- - .---. - .##.- - *--.* - # .█* - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_14.txt b/codex-rs/tui2/frames/hash/frame_14.txt deleted file mode 100644 index 8eca9095040..00000000000 --- a/codex-rs/tui2/frames/hash/frame_14.txt +++ /dev/null @@ -1,17 +0,0 @@ - - #**** - #A--.* - .-... . - ..A.█-.# - ...* . - .*.. █. - .... . * - ..*.- * - .... █ - █-AA *A - *.██#.#. - .*..---. - A █.***- - .*A*--A - -* █*A - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_15.txt b/codex-rs/tui2/frames/hash/frame_15.txt deleted file mode 100644 index cbf646ab35c..00000000000 --- a/codex-rs/tui2/frames/hash/frame_15.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ##***.- - -#*..-A*# - AA.***.█*. - .A- AA#.--. - ..*█**..A█-. - *..-..AA. █ - .. ......#. - ..A..#... █-. - ..###*...-.A. - .-.*A.*.*. .. - .A....-. - * - .**.-..*- * - █.*# AAA- *- - .-..**.#*A - *-*----A - -- \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_16.txt b/codex-rs/tui2/frames/hash/frame_16.txt deleted file mode 100644 index 82698755af1..00000000000 --- a/codex-rs/tui2/frames/hash/frame_16.txt +++ /dev/null @@ -1,17 +0,0 @@ - - -*#█**#.- - A-..*..*.** - .AA█*A**.. █* - AAA **-*.*..**. - ..*-A*█*..*A.-* - ...*.█ .** . ... - **. A-#....A*█# . - .A* .-..A...█* -. - **...#.A-..█*# A - *█--#****..-. #-. - ...#██A**.*█*...- - ..* .- -AA# A - *.* . A#A-#. - *..█-*.AA#-. - █A.-*A--** - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_17.txt b/codex-rs/tui2/frames/hash/frame_17.txt deleted file mode 100644 index 57d02179e70..00000000000 --- a/codex-rs/tui2/frames/hash/frame_17.txt +++ /dev/null @@ -1,17 +0,0 @@ - - #*###**.#- - -***...***.#█-# - #**A-**-##█...-█* - A.A-A.█-- █.**...** - .A- . .█A***AA # - -**#A- #AA. A#*A..A - * *A. #AA.- *█..A*. - -█*. AA..A.#..*** - #A*A***#.*-.*-A*... . - .█-*--.A**A..* *#█#* - ***A███*****-.*AA* *█ - *..-* -A..- * - -** **- #█#A--A - # .*#**#--**A.█ - -#*A-##.*-#* - - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_18.txt b/codex-rs/tui2/frames/hash/frame_18.txt deleted file mode 100644 index ef524a0ed91..00000000000 --- a/codex-rs/tui2/frames/hash/frame_18.txt +++ /dev/null @@ -1,17 +0,0 @@ - - -**#**#*.#- - -#█-*###.--#█#**- - -AA.*█**█#█.-█#AA* * - #..*.#-█* █*--A*.*****# - -*AA A. -█#-*A.A.-****- - A*. ##..---A#*#.█-A-A-A. - ..*AA #A*A.█-A█*.*.*# - █*-.. -*█..*- .*.█ - *██ #******#..#.*A*# .*.- - -**.*** ..A--*-...-*.-* # - .A.*-██*****- *--A.A*A--- - ** #*# --*█A*-█ - *-*#--. -*--A*#- - █..**##***-█*-A-#* - █..-A--#.#█*#█ - - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_19.txt b/codex-rs/tui2/frames/hash/frame_19.txt deleted file mode 100644 index 80a9abf0128..00000000000 --- a/codex-rs/tui2/frames/hash/frame_19.txt +++ /dev/null @@ -1,17 +0,0 @@ - - --**#.#**..- - -#...*A***-*-#.*## - #***A█.#*- -*A#-*** - A*.*#-- -**##-.*# - ..A-A- #.-█A█.* .*# - .*A*█ -.*.A .A █.*. - -*..█ A*-A.##- AAA#. - .... .*█*.█# . ... - .*.*#******....#***.* * █.. - -*A#.**A-*#*#-# -- A*-*#A..- - █*█A#.█████**█ -*-A.AA AA█ - **-*-* -AA-AA█ - █ -*..*# #**.A.*- - ██..*A.#--**A*█ **.█ - █..-A-█-#.-*-- - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_2.txt b/codex-rs/tui2/frames/hash/frame_2.txt deleted file mode 100644 index 843df90f283..00000000000 --- a/codex-rs/tui2/frames/hash/frame_2.txt +++ /dev/null @@ -1,17 +0,0 @@ - - -.-A*#***##- - -##A**#...*..*-█.*#- - #.*.***.*-- -█***A..*.# - -.A-A*█##- -#*.A.# - #-█A*-.*-**- -#.*.# - █A#█- .**#A*# **.*. - #.█*. -A.*.-.# A█.█. - .█ *- #.*..** .█A.. - *██*. *.---.█..#########.-..* - -*█. A..█ A* .**----..--.#A . - *** * **...█ -█*******█.#*.. - █*-.A# - ##*A#* - .*--.## #*-# -.█ - -.-*--█*#A****.**--..* - -*#*#..----.*A*█ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_20.txt b/codex-rs/tui2/frames/hash/frame_20.txt deleted file mode 100644 index b588df38946..00000000000 --- a/codex-rs/tui2/frames/hash/frame_20.txt +++ /dev/null @@ -1,17 +0,0 @@ - - --#*..*#*#-- - #**█*#-#***..--**## - ##█-#*█.*█ -█-█*#*.#- - -.█-*.* -##.*A*** - -. #.* -A -..-**..* - A-#.█ #*█*.█A █*..* - *.*#. #* .#*- #*.A - .--A- ██*A.█# ..█. - .A..*#********-#█*--*** A .* - █.-.-█ A------.* ***█.** #A .. - *#-*████████*█ *#*..#. A* - * -*A# -A*..A - *--*#.- #**-**█ - -#-.█.#-**#A█.█-#A*█ - -*#.- *----.*██ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_21.txt b/codex-rs/tui2/frames/hash/frame_21.txt deleted file mode 100644 index 0d1fc7ec26e..00000000000 --- a/codex-rs/tui2/frames/hash/frame_21.txt +++ /dev/null @@ -1,17 +0,0 @@ - - --#*#**#*##-- - ##*----#.*.#*---*#- - **--#*-█- --.*#--*# - #*.*██ -#**#**# - A#A*- #.A-* **--# - A█A. #**-AA **-# - .█.- - A-#A█ ***█ - -* ** █.# . . - . . ##*****### -.#█.# *A . - . .* .- -#. -█-*.# A█.A - * █. --███ █- █*-#* A- # - █# A*- -*█ A - *- --- -.**█ #- - **- *█*#A..*A**██ -- - *##*#----#-*#*- - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_22.txt b/codex-rs/tui2/frames/hash/frame_22.txt deleted file mode 100644 index 8fbfdb57138..00000000000 --- a/codex-rs/tui2/frames/hash/frame_22.txt +++ /dev/null @@ -1,17 +0,0 @@ - - -##*#**#*##- - -#A*..-A.A*..*.#A*#- - #***.***- -█****#--## - **.**█ ##...-** - *..A█ #-A.*..A*█. - *.*A- #.A.*-A- *.**. - .#**A .#.**A█ ..A. - ..*.- .*..-* .-.█. - ..#█# #.******##-**.- *# -.*█ - -.* *-**# A#█.AA. ***-█.## A** - ***-. █████**█- -A*#-AAA█#- - ***-*# -*#█.#█ - ██#* ##- -.*#█*-A - █A*##█*.**#**A**███#* - █.█.-*----*-.**- - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_23.txt b/codex-rs/tui2/frames/hash/frame_23.txt deleted file mode 100644 index ef2f8adb709..00000000000 --- a/codex-rs/tui2/frames/hash/frame_23.txt +++ /dev/null @@ -1,17 +0,0 @@ - - -##-*#*##*#-- - -*.#.*-#-**#.-.*.#- - #█#.█#**- █.A#.**..# - A*.**A- -***..** - AA.-A█ #A##█*A**.* - #*.█A- -***██A*█..*.# - -..█. AAA. -.- █ .█* - .A. . .█.#-*# . *A. - -█* .#.********.-*.-█*- . ... - .**-.█*█ A-* *.-*#. *-. - A█. . -███████ █-A**.-A-A- - #..-.. #A.*-A- - * .#█A-- -.█**A#A - ***#**#*.**-##█--#*- - █. ***.--#--**- - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_24.txt b/codex-rs/tui2/frames/hash/frame_24.txt deleted file mode 100644 index 09a7fd520cb..00000000000 --- a/codex-rs/tui2/frames/hash/frame_24.txt +++ /dev/null @@ -1,17 +0,0 @@ - - -.-*##***#- - #█-A█.--.**##-█# - #██**A#█. .-A*.**# * - #█AA#A.*#*-.A- -**.*-█ - # AA*A# -██A*-.--*.#█ - .#*#*- *##A ..* A█* - . .. #A-#A█A-*.*-.- - . *.. .#*A*-..-A.-.. - .. .***A##...##-*#-. . .. - - A *.A##**##..-*#*. .*. - * █*.**██████- *#.**.*A#- - * *#** . █A*A - *.**-.. -*█-AA-* - .-*#*-█..A***.--A- - *A-*#**--**#* - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_25.txt b/codex-rs/tui2/frames/hash/frame_25.txt deleted file mode 100644 index af8bb947f60..00000000000 --- a/codex-rs/tui2/frames/hash/frame_25.txt +++ /dev/null @@ -1,17 +0,0 @@ - - -#***####- - # *#*--**.*..# - A.AA##█..#***...- - .#*. A *. █*.#**A - #--*#A█-*A-.█ .AA*A* - *#...-*#.-.A A.#.AA.# - .A.*. .--A*-A.*A-█.*. - ...A..█ .-**A.*-.*..* - -#*.█.--****#..**.#*.* - A-█.A.#.-..**-.***A*. - *.*A*.-██████#A.*.A.- - .-█.*#**-#*A-A#AA.A - ---.-*-**A#-A-..A - *.***-**.** *.* - .#█**##-#.* - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_26.txt b/codex-rs/tui2/frames/hash/frame_26.txt deleted file mode 100644 index 7ff85c300af..00000000000 --- a/codex-rs/tui2/frames/hash/frame_26.txt +++ /dev/null @@ -1,17 +0,0 @@ - - -****█## - A*.*A*.#*.# - A*██.**.*██*# - **█#...-..-#A- - .-#*...█**A*.** - * █.AAA█*-*█**A - . *█*..A..**█# - A- *...--*..A.. - . .***#.#**A*.. - .-.A#.--#****. - .-█A.*█*#A-#*.. - *-..A* AA█..- - * *.* --A.A# - .█***█*-*** - *AA-*.-** - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_27.txt b/codex-rs/tui2/frames/hash/frame_27.txt deleted file mode 100644 index 06e988b0761..00000000000 --- a/codex-rs/tui2/frames/hash/frame_27.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ****## - .*AA.A* - *█- *.A█. - ...-..*.. - . -.--█* - .-.-█-...- - * █..-*.. - . -...*█.. - .. . .#.... - █* .█-.-*.. - .- --**#A█ - ..##..-A. - .**#*#A.- - *--.**AA - █*█ -*A - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_28.txt b/codex-rs/tui2/frames/hash/frame_28.txt deleted file mode 100644 index 0e258181458..00000000000 --- a/codex-rs/tui2/frames/hash/frame_28.txt +++ /dev/null @@ -1,17 +0,0 @@ - - A*** - .#-.- - * *.- - .--*. - . .. - . -█. - -.. -. - .-██. - A* -. - ...A.. - . -. - .*#*. - .**.. - *--A. - . .#. - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_29.txt b/codex-rs/tui2/frames/hash/frame_29.txt deleted file mode 100644 index 7f2ddab00a4..00000000000 --- a/codex-rs/tui2/frames/hash/frame_29.txt +++ /dev/null @@ -1,17 +0,0 @@ - - #****# - #.*A ** - -#.. ██. - ....-*#A - * .█. . - .#... -- - .**... - - ..*.. - ...*. - - -.*.. - - *..A.- * - .A... -. - ....-.-. - .*.*A█.█ - ..* .# - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_3.txt b/codex-rs/tui2/frames/hash/frame_3.txt deleted file mode 100644 index 8cce426bb4a..00000000000 --- a/codex-rs/tui2/frames/hash/frame_3.txt +++ /dev/null @@ -1,17 +0,0 @@ - - -.**##****#- - -##A*..#.█**.*--.*#- - #*A**.*██- -█. **#A-.# - A#A*.*##- -** ..* - █A*.A-A..**- *-*..- - A.**A .#.**** . **. - ** . █**.-█.# .*A.# - #-.#. #-.█A*. .-.*. - -██. *.*#..*-.*##---##-.-█*. - #*A*# .A*A**. .*.--# *.#**-.*.- - █-. . █..##█ █***███**#AAA#A - █*. *# - A-#-AA - *-AA**# ##*A█#.█ - *-##-**-****#A***--#█ - -*#*-A.----.*A-█ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_30.txt b/codex-rs/tui2/frames/hash/frame_30.txt deleted file mode 100644 index 24a2165e45b..00000000000 --- a/codex-rs/tui2/frames/hash/frame_30.txt +++ /dev/null @@ -1,17 +0,0 @@ - - -*█**## - -A*..**█** - ██-.#**.##* - ..*.A.-*.* *# - **..-*.#.# . - .*.-# *...... - *A...*- *A*. *- - █. █#*.#.█.- - - █.-█*.*.*A.# - - █..*.--.. ## - - *..**-*...#.. - .*AA#A**..█ . - *.*..A*..#.A - #-##*-A.#A█ - *-..---*█ - ---- \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_31.txt b/codex-rs/tui2/frames/hash/frame_31.txt deleted file mode 100644 index 65f139ab962..00000000000 --- a/codex-rs/tui2/frames/hash/frame_31.txt +++ /dev/null @@ -1,17 +0,0 @@ - - -.A*#*#*# - -#**##-█#*█*# - #.*AA#*--.█.-** - #.A#--**-#**.*..* - -....#-**-#█*.A-- - *.--.-.██***#.A.#█. - A.. .-*.*.*A**.*.A. - **# ..█.* .-██..*.. - .*#-.█-...A#...- . - █...AA..---*-.*.. * - -*-A#.# A***-A.█.- - ...***# -** . - *.**- # . .*-A- - A.*.A-.-#.█#.- - ██*.-#*A.** - ---- \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_32.txt b/codex-rs/tui2/frames/hash/frame_32.txt deleted file mode 100644 index 6cbec21aeca..00000000000 --- a/codex-rs/tui2/frames/hash/frame_32.txt +++ /dev/null @@ -1,17 +0,0 @@ - - #####**.#- - **.**.*..**..# - A-**-##-A--*.**█*# - █AA.-#-*- - #-*.## # - AA.#.**█*.* █-#.*A***# - *A**#**.-A..#- .#A * - *.█*#-█*..##.#*-A-AA.█- - ...#- █-..█A█.*--█*.██- - *..-- █-.█--.###...*.█*- - ███--.**█*-----..*.*.-- - #**..A .*██***-*AAA.A - *#.-** #*.A█. - █**...#- -A-.A # - *-*█.#.***--#█-A - .**--##A*-██ - --- \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_33.txt b/codex-rs/tui2/frames/hash/frame_33.txt deleted file mode 100644 index a661feb2aff..00000000000 --- a/codex-rs/tui2/frames/hash/frame_33.txt +++ /dev/null @@ -1,17 +0,0 @@ - - -##*#*#*..- - -#*..***.A.-.*.*. - -A*.#*##*█- █*A..#█# - #*A.A-.#- *..*-* - -..A-..A*-* .#**- - AA# *-**-*# A*A-* - ..# *#*.-.* .*.. - *.## #██*A*# *█.* - ... . #- A*###***#..#A-.*. - -A.██AA#A-..-----..*---... - ***# .A.A A ████*** ..#A-- - *#**#█# AA*A*A - **█*#**# -#.#A**█ - *.█**#.#*#-█*#**.* - █**##----#**-█ - ---- \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_34.txt b/codex-rs/tui2/frames/hash/frame_34.txt deleted file mode 100644 index 3427025326c..00000000000 --- a/codex-rs/tui2/frames/hash/frame_34.txt +++ /dev/null @@ -1,17 +0,0 @@ - - -#*####*.#- - -#*#A**.█.#*****#*- - -AA#.*█A.*- --**#█**# - #A-.*-A#- *.#*** - AA.*-A. .█ # ****- - #A*.-A **█.*** *#*.* - .*-.- █*-*.-- .*.- - *.*█A .*-.A█. A -* - #█A.█ A*AA█-**######.--█ .-- - ..*#A##-█AA█...-..---#A-..-.* - A-*# .*.*- █**----- #A#*.█ - A#****- A*..A█ - *#-.#**# -#*█-A#- - -*A.█.##*##****-#*A█ - █.***-----****- - --- \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_35.txt b/codex-rs/tui2/frames/hash/frame_35.txt deleted file mode 100644 index e0919ec5d0e..00000000000 --- a/codex-rs/tui2/frames/hash/frame_35.txt +++ /dev/null @@ -1,17 +0,0 @@ - - -##**###*##- - #***..**#█.*.A█.**- - -AA.*#*.-*█- -█***.█.*# - #AA#A#*#- *#-*.- - AA..AA* █**# .**.# - *A*A#- █.-█..*- .**. - * .A ** █*-. .-** - ...A A***A█ A █A - A #- #..A*█A.**-*-*#.. ..** - .#-*█ AA*█*#A.* *#----A█.A█ .█ - A-*#█A*-.A-- -█----*--*AA-#. - █.-*A. -A* #- - **--*#A# #*A-**█ - █--█.*.-A..#.*A*█-A*█ - █-.**--A--##*█- - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_36.txt b/codex-rs/tui2/frames/hash/frame_36.txt deleted file mode 100644 index 0355f68b47c..00000000000 --- a/codex-rs/tui2/frames/hash/frame_36.txt +++ /dev/null @@ -1,17 +0,0 @@ - - --#*####*##- - -*██*#A*A#*#*##█**#- - #*█*##*-*█-- -█*.*#.#*.# - -.█*#---- █*.*█. - #A-**█.*-*#. -*.* - A #A- *- █.A# █*-* - . * - * █** .-.# - ..*. -* -AA *. . - .██. AA #*██###-#####- .*.. - -*.* #.--A.A -.-------.. *A█- - █ *.* #-#A- █**-----█ .#█A - █#█**. #A.█A - *A..#.# ###█A#- - *#-█***-*.-#.*█-█#*#█ - -*#A.-##-####**█ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_4.txt b/codex-rs/tui2/frames/hash/frame_4.txt deleted file mode 100644 index 2b4b7c670bb..00000000000 --- a/codex-rs/tui2/frames/hash/frame_4.txt +++ /dev/null @@ -1,17 +0,0 @@ - - -.**#**#*##- - --█#*.A .**A**.█A*#- - #**...#*█- --.*-*.A**- - AA.#.█.## █.***** - A.A-.*.**-.# *█*.** - A..-. █-..A** █ **.# - █*.*- █***#█.# # █*. - ..* #.A*-.. ... - -. .- *.A█-AA#.###----## ..* - .*A . #AAA#-.-**.#.-#AA-*.AA█. - -.*█*#**#*A █*******█--...- - #.*█.-- #-*A**█ - **#A- #- -.-#**.A - ...*-*******##**#*.*- - ***.A.-----*-*- - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_5.txt b/codex-rs/tui2/frames/hash/frame_5.txt deleted file mode 100644 index c71575690bb..00000000000 --- a/codex-rs/tui2/frames/hash/frame_5.txt +++ /dev/null @@ -1,17 +0,0 @@ - - -.***#***##- - -#█#A*A..#.*#*-.█## - *-A*#.*█- █-*.** #*# - #.A.A..*# --....- - #*A*A--#.-*# - *.*.- - ....A *-.**A.- #A.. - .A. # -*-.*█.# ██.#.# - .A.-█ #-*.A-. #.-. - .#.-* A-.█#.**-.#.---##-█... - -*.*##A#*█ .█ AA**-#*.#A.A*.- - *#..#.#**A#- █**█████#.*.*A - **#A** - A-A*#- - -A**.*#- -*##*█** - -.*.#*#**.*-##**-.** - --*-*█-----##*- - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_6.txt b/codex-rs/tui2/frames/hash/frame_6.txt deleted file mode 100644 index 799e3a1cf5a..00000000000 --- a/codex-rs/tui2/frames/hash/frame_6.txt +++ /dev/null @@ -1,17 +0,0 @@ - - -.***#**##-- - #-*A*.**#.##*-#*#- - ###...*█ -█..**#*A*- - A#.*.*-*# *#....# - █*...***#** .**.*# - *-A.A--#*.*-.# .**A. - - #.. -*.A-* -#.-. - . ... -.*.A.. .#*.. - -..-. A█..**A#-.#.--##-.*A. - ..*#A---*# .A**#..#****.#.* - A . A...*A█ █*████*█-**A. - # * A#- #A-AA.- - .-*-**# -#--A*## - -█.*#█# *.--##** A*█ - --**##-----##* - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_7.txt b/codex-rs/tui2/frames/hash/frame_7.txt deleted file mode 100644 index 4a3f9f202fb..00000000000 --- a/codex-rs/tui2/frames/hash/frame_7.txt +++ /dev/null @@ -1,17 +0,0 @@ - - -..**#**##- - #*-#*.#*#**** -*# - * #...*- --.- *A-*# - **....*# -.**.A# - -█.*..-.A*# . *..* - . *..█ -█**.* #-... - -*█. --.*.*. **-.. - -█A.. *#A.*.- █ ... - A█.* #█-A.*A..**-..****.. - .#█..*A..A█..*..**#*#**.*. - * *-A*.*-A ******██-A.. - █- A-A-- #█-A*A█ - █*##***- #-.#*A*█ - *-█*.***.---#*.*#A - █.*█.A.--#-*** - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_8.txt b/codex-rs/tui2/frames/hash/frame_8.txt deleted file mode 100644 index 4bc5a6f1186..00000000000 --- a/codex-rs/tui2/frames/hash/frame_8.txt +++ /dev/null @@ -1,17 +0,0 @@ - - --#-*##*##- - #█*A...*** . █# - -.*#.AA* #█.A.**#-# - -***A.##. █ *-..* - █ A.A#*.A* .█*..# - .█#.*---*.-* *#... - .*... █-**.-. .-.A.# - *#..A .#*A.#*. .-A.. - * *..-- A**..A#####*... - **A*-.█#.#..*.*--..*█ - AA**.*...A█-*-*█**█A*. - █#█.*.-- .#AAA█ - █*-****- -A*-.#A* - .A█.#* **#**A*#.- - ..-***.-##A*- - -- \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_9.txt b/codex-rs/tui2/frames/hash/frame_9.txt deleted file mode 100644 index db3507db59c..00000000000 --- a/codex-rs/tui2/frames/hash/frame_9.txt +++ /dev/null @@ -1,17 +0,0 @@ - - .*-*A##*# - A*-A..*█* --*- - A #..A*--*A *-.# - █ ..A**- --#.*-.## - A*█***█.*# *.█#A#.. - . ...█-.**-- .█.*... - - .**.**.**#█.#A -.. - . .A-#*...-A**-. .. - █#.█ A.█....#..#..- - .-...-#.-.-....--... - █ --A**.A█****..█A*. - *...*#- #A -A.- - *#*.** A*-*.A* - --█*-.*A#****.- - █-- ***.*#A█ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_1.txt b/codex-rs/tui2/frames/hbars/frame_1.txt deleted file mode 100644 index ab8be3eb1e1..00000000000 --- a/codex-rs/tui2/frames/hbars/frame_1.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▂▅▂▅▄▇▇▄▄▇▆▂ - ▂▄▆▅▇▃▇▅▇▃▄▁▁▄▂ ▂█▇▂ - ▆▁▇▁▇▇▇█▃▂ ▂█▇▂█▁▄▁▁▁▇ - ▄▇▂▃▇█▆▆▂ ▅▇▁▄▁▆ - ▃▃▄▅█▃▁▃▂▃▃ █▅▁▃▃▆ - ▁▇ ▇▂ ▁▇▅▄▁▁▆ █▅▃▁▁▆ - ▇▃█▆▇ █▃▁▇▅█▁▂ ▁▅▁ - ▁▁▂▁▂ ▆▅▅▁▄▁▇ █▂▁ - ▁▄▁█▂ ▄▁▁▃▃▁█▅▁▇▇▇▇▇▇▂▇▆ ▄█ ▁ - ▂▁▄▇ ▂▄▇▂ ▅▇ ▁█▁▂▂▂▅▅▆▆▆▁▅▆▅▆▁ - ▃▃▂█▃ ▃▃▆▅▅▂ ▂▃▇██▇ ▃▇█▅▆▄▂▅ - ▇▃▆ █▆ ▂ ▆█▅▇▂▁ - ▃▃▆▂▃▇▂ ▂▄▂▇▁▂▇█ - ▃▇▆▃▂ ▇▇▅▄▄▄▄▅▄▇▇▂▆▁▇ - ▂▇█▇▁▁▁▂▂▂▆▂▄▇▇█ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_10.txt b/codex-rs/tui2/frames/hbars/frame_10.txt deleted file mode 100644 index 5e565ce40b9..00000000000 --- a/codex-rs/tui2/frames/hbars/frame_10.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▂▇▇▇▇▃▇▇▂ - ▇█▂▇▇▇▃▁▅▂▇▆ - ▃▂▆▁▁▅▁▁▆▁▇▃▆▆ - ▁▂▂▅▃▁▄▂▅▃▆██▃▃▆ - ▅ ▄▁▁█▁▃ █▆▅▂▅▁▅ - ▁▂ █▁▇▁▅▅▃ ▂▂█▁ - ▃ ▁▇█▇▁▁▁▁▇ ▁▅▆ - █ ▁▁▃▇▅▇▁▁▆▂▂▅▃▁▁ - █ ▁▃ ▃▃▁▄▁▁▇▃▇▄▁▁ - ▁ ▆▁▃▆▁▂▅▂▇▂▂▂▁▇▂ - ▆ █▁▁▁▁▁██ ▃▆█▃▁▂ - ▃▂█▆▃▆▇█ ▂▁██▆▅▅ - ▁█ ▁▁▁▁▇▆▅▆▅▁▅▂ - ▄▂▇▇▅▁▇▄▂ ▅▅█ - ▇▆ ▂▇▃▂▆▄▇ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_11.txt b/codex-rs/tui2/frames/hbars/frame_11.txt deleted file mode 100644 index 5305252a8d1..00000000000 --- a/codex-rs/tui2/frames/hbars/frame_11.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▆▇▇▇▇▇▇▂ - ▅█ ▄▁▁▁▃▃▆ - ▅█▂▂▁▁▄▃▇▅▃▆ - ▁▂▂▁▁▄▄▆▂▄▅▅ - ▂█▅▄▃▁▇▃█▄▂▁▁▆ - ▁▇▂▁▁▇▂▄▄▁▂▅▁▁ - ▇▇ ▆▁▁▁▁▁▁▂▁▄▃ - ▁ ▁▁▅▁▁▃▅ ▁█▁ - ▅ █▁▅▁▁▇▅▇▁▂▁ - ▇▇ ▂▁▅▄▆█▅▁▂▁▃ - █▂▆ ▁▁▁▁▄▅▁▃▃▁ - ▃▂▆▅▁▂▇▅▇▇▄▁▂ - ▂ ▇▁▃▃▃▂▁▄█ - ▃▇ ▇▁▁▆▂▅▇▂ - ▃▂ █▇▇▂▇▂ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_12.txt b/codex-rs/tui2/frames/hbars/frame_12.txt deleted file mode 100644 index cebfe226e1e..00000000000 --- a/codex-rs/tui2/frames/hbars/frame_12.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▇▇▇▇▇▅ - ▆▄▂▂▅▁▆▃ - █ ▁▅▃▇▆ - ▅▇▁▁▁▄▄█▁ - ▁ █▁▁▁▁▅▅▁ - ▁█▅▅▇▃█▂▁█ - ▁ ▁▅▃▁▁▃ - ▁ ▅▇▃▁▁█ - ▇▆ ▁▅▁▁▇▁▁ - ▁ ▅▁▇▄▇▂ - ▁▅ ▁█▄▇▇▅ - ▆ ▇▁ ▂▆▁ - ▅ ▇▇▁▆▇▃▁ - █▃▅█▆▁▃▁▂ - ▃ █▁▃▅▅ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_13.txt b/codex-rs/tui2/frames/hbars/frame_13.txt deleted file mode 100644 index 566cc4ffa30..00000000000 --- a/codex-rs/tui2/frames/hbars/frame_13.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▅▇▇▇▆ - ▁▂▂▁▁ - ▁▂▂▁█ - ▁▇▇▁▂ - ▇ ▁▁ - █▅▆▁▁ - ▆ ▁▁ - ▇ █▁ - ▅▇ ▂▁▅ - ▁█ ▇▄▁ - █▂▅▅ ▁▂ - ▁▂▂▂▁ - ▁▇▆▁▂ - ▇▂▂▁▄ - ▆ ▅█▄ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_14.txt b/codex-rs/tui2/frames/hbars/frame_14.txt deleted file mode 100644 index 380790e11c9..00000000000 --- a/codex-rs/tui2/frames/hbars/frame_14.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▇▇▇▇▄ - ▆▅▂▂▅▃ - ▁▂▁▁▅ ▁ - ▁▁▅▁█▃▁▆ - ▁▁▁▃ ▁ - ▁▇▁▁ █▁ - ▁▁▁▁ ▅ ▇ - ▁▁▃▁▂ ▃ - ▁▁▁▁ █ - █▃▅▅ ▄▅ - ▃▁██▆▅▆▁ - ▁▇▁▁▂▂▂▁ - ▅ █▁▄▄▄▂ - ▁▃▅▇▂▂▅ - ▂▇ █▄▅ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_15.txt b/codex-rs/tui2/frames/hbars/frame_15.txt deleted file mode 100644 index 47d169e98bc..00000000000 --- a/codex-rs/tui2/frames/hbars/frame_15.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▇▇▇▇▇▁▂ - ▂▆▄▁▁▃▅▇▆ - ▅▅▁▇▄▃▁█▇▁ - ▁▅▂ ▅▅▆▅▂▂▁ - ▁▁▄█▄▃▁▁▅█▃▁ - ▃▁▁▆▁▁▅▅▁ █ - ▁▁ ▁▁▁▁▁▁▆▁ - ▁▁▅▁▁▇▁▁▁ █▆▁ - ▁▁▇▆▆▇▁▁▁▂▅▅▁ - ▁▂▁▄▅▁▃▁▇▅ ▅▁ - ▁▅▁▁▁▁▂▁ ▂ ▄ - ▁▇▇▅▃▁▁▃▆ ▄ - █▁▃▆ ▅▅▅▂ ▄▂ - ▁▃▁▁▃▃▅▇▃▅ - ▃▃▇▃▂▂▂▅ - ▂▂ \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_16.txt b/codex-rs/tui2/frames/hbars/frame_16.txt deleted file mode 100644 index 3b1fb1fc5d4..00000000000 --- a/codex-rs/tui2/frames/hbars/frame_16.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▂▄▇█▇▇▇▁▂ - ▅▃▁▁▇▁▁▃▁▄▃ - ▁▅▅█▃▅▄▃▁▁ █▃ - ▅▅▅ ▄▇▂▃▁▃▁▁▇▇▁ - ▁▁▄▂▅▇█▄▁▁▇▅▁▂▃ - ▁▁▁▇▁█ ▁▃▄ ▁ ▁▅▁ - ▃▃▁ ▅▂▆▁▁▁▁▅▇█▆ ▁ - ▁▅▄ ▁▂▁▁▅▁▁▁█▄ ▂▁ - ▃▃▁▁▁▇▁▅▃▁▁█▇▇ ▅ - ▇█▂▂▆▄▄▃▇▁▅▂▁ ▆▂▁ - ▁▁▁▇██▅▇▃▁▄█▄▅▁▁▂ - ▁▁▇ ▁▂ ▂▅▅▆ ▅ - ▃▁▇ ▁ ▅▆▅▂▆▁ - ▃▁▁█▂▇▁▅▅▇▂▁ - █▅▅▂▄▅▂▂▄▇ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_17.txt b/codex-rs/tui2/frames/hbars/frame_17.txt deleted file mode 100644 index 93817e2eadd..00000000000 --- a/codex-rs/tui2/frames/hbars/frame_17.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▆▄▇▇▇▄▄▁▆▂ - ▂▄▇▇▁▁▁▇▄▇▁▆█▃▆ - ▆▇▃▅▂▄▄▂▇▆█▁▁▁▂█▃ - ▅▁▅▂▅▁█▂▂ █▁▄▃▁▁▁▄▃ - ▁▅▂ ▁ ▁█▅▃▄▃▅▅ ▆ - ▂▄▇▆▅▂ ▆▅▅▁ ▅▆▄▅▁▅▅ - ▇ ▄▅▁ ▆▅▅▁▂ ▇█▁▁▅▄▁ - ▆█▄▁ ▅▅▁▁▅▁▆▁▁▄▄▇ - ▆▅▇▅▃▄▄▇▁▃▂▁▃▃▅▃▁▁▁ ▁ - ▁█▂▄▂▂▁▅▇▃▅▁▁▃ ▃▇█▇▃ - ▃▃▃▅███▇▇▇▇▃▂▁▇▅▅▃ ▃█ - ▃▁▁▂▇ ▂▅▁▁▂ ▄ - ▂▃▃ ▇▃▂ ▆█▆▅▃▂▅ - ▆ ▁▇▇▄▃▇▂▂▄▇▅▁█ - ▂▇▄▅▂▆▇▁▇▂▇▇ - ▂ \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_18.txt b/codex-rs/tui2/frames/hbars/frame_18.txt deleted file mode 100644 index 03d2c5e94b8..00000000000 --- a/codex-rs/tui2/frames/hbars/frame_18.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▂▄▄▇▄▄▇▄▁▆▂ - ▂▇█▂▄▆▇▇▁▂▂▆█▇▄▄▂ - ▂▅▅▁▇█▇▄█▆█▅▂█▇▅▅▃ ▇ - ▆▁▁▇▅▆▃█▇ █▃▂▂▅▄▁▃▄▃▃▃▆ - ▂▃▅▅ ▅▁ ▂█▇▂▃▅▁▅▁▂▃▄▃▃▂ - ▅▃▁ ▆▆▁▅▂▂▂▅▆▇▆▁█▆▅▃▅▂▅▁ - ▁▁▃▅▅ ▇▅▇▅▁█▂▅█▇▁▄▁▄▆ - █▃▆▁▁ ▃▃█▁▁▄▃ ▁▄▁█ - ▃██ ▆▃▄▄▄▄▄▇▁▁▆▁▇▅▃▆ ▁▇▁▂ - ▂▃▃▁▇▃▇ ▁▁▅▂▂▃▂▁▁▁▂▄▁▂▃ ▆ - ▁▅▁▃▂██▇▇▇▇▇▂ ▃▂▂▅▁▅▇▅▂▆▂ - ▃▃ ▆▃▆ ▂▂▇█▅▇▂█ - ▃▃▇▇▃▃▅ ▂▇▃▂▅▃▆▂ - █▁▅▇▇▇▇▄▄▄▃█▇▂▅▂▆▇ - █▁▁▂▅▂▂▆▅▇█▄▇█ - ▂ \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_19.txt b/codex-rs/tui2/frames/hbars/frame_19.txt deleted file mode 100644 index f8267761700..00000000000 --- a/codex-rs/tui2/frames/hbars/frame_19.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▂▂▄▄▇▁▇▄▄▁▅▂ - ▂▇▁▁▁▃▅▄▄▄▂▇▂▆▁▇▆▆ - ▆▇▄▄▅█▁▇▇▂ ▂▇▅▆▂▇▄▃ - ▅▄▁▇▆▃▂ ▂▄▄▇▆▃▁▇▆ - ▁▁▅▂▅▂ ▆▁▃█▅█▁▃ ▁▃▆ - ▁▃▅▇█ ▂▁▇▁▅ ▅▅ █▅▇▁ - ▃▄▁▁█ ▅▇▃▅▁▇▆▂ ▅▅▅▇▁ - ▁▁▁▁ ▁▄█▃▁█▆ ▁ ▁▁▁ - ▁▇▁▃▆▄▄▄▄▄▄▁▁▁▁▇▇▄▇▁▄ ▃ █▁▁ - ▃▄▅▆▁▄▇▅▃▇▇▃▆▂▆ ▃▂ ▅▃▂▃▆▅▅▁▂ - █▃█▅▇▁█████▇▇█ ▃▃▂▅▁▅▅ ▅▅█ - ▃▃▃▇▂▃ ▂▅▅▂▅▅█ - █ ▂▃▁▁▇▆ ▆▄▄▅▅▁▄▂ - ██▅▁▇▅▁▇▂▂▄▄▅▇█ ▄▇▁█ - █▁▅▂▅▆█▂▆▅▃▇▆▃ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_2.txt b/codex-rs/tui2/frames/hbars/frame_2.txt deleted file mode 100644 index d4efa4def0e..00000000000 --- a/codex-rs/tui2/frames/hbars/frame_2.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▂▅▂▅▄▇▄▄▄▇▆▂ - ▂▇▆▅▇▄▇▁▁▁▄▁▁▄▂█▁▇▇▂ - ▆▁▇▁▃▇▇▁▇▂▂ ▂█▇▄▇▅▁▁▄▁▆ - ▂▁▅▂▅▇█▆▆▂ ▆▆▃▁▅▁▆ - ▆▃█▅▇▃▁▃▂▃▃▂ ▃▆▁▃▁▆ - █▅▇█▂ ▁▇▃▇▅▃▇ ▃▃▁▇▁ - ▆▁█▇▁ ▃▅▁▄▁▂▁▆ ▅█▁█▁ - ▁█ ▃▂ ▆▁▇▁▁▄▇ ▁█▅▅▁ - ▇██▄▁ ▄▁▃▃▂▁█▅▁▇▇▇▇▇▇▇▇▆▁▂▁▁▇ - ▂▄█▁ ▅▁▁█ ▅▇ ▁▄▃▂▂▂▂▅▅▂▂▁▇▅ ▁ - ▃▃▃ ▇ ▇▃▅▅▁█ ▂█▇▇▇▇▇▇▇█▁▆▇▁▁ - █▃▂▅▅▆ ▂ ▆▇▄▅▆▇ - ▅▃▂▂▁▇▆ ▆▄▂▇ ▂▁█ - ▂▁▃▄▂▂█▇▇▅▄▄▄▄▅▄▇▂▂▁▁▇ - ▂▇▇▇▇▁▁▂▂▂▂▁▄▅▇█ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_20.txt b/codex-rs/tui2/frames/hbars/frame_20.txt deleted file mode 100644 index 30c29f51c9b..00000000000 --- a/codex-rs/tui2/frames/hbars/frame_20.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▂▂▇▄▁▁▄▇▄▆▂▂ - ▆▄▄█▄▆▂▆▄▄▄▁▁▂▃▇▃▇▆ - ▆▆█▂▇▇█▁▇█ ▂█▃█▃▇▇▁▇▂ - ▂▁█▂▇▁▇ ▂▆▇▁▃▅▇▃▃ - ▂▁ ▆▁▇ ▂▅ ▂▁▁▃▇▃▁▁▃ - ▅▂▆▁█ ▇▇█▄▁█▅ █▃▁▁▃ - ▄▁▄▇▁ ▆▇ ▅▇▇▃ ▆▃▁▅ - ▁▂▃▅▂ ██▄▅▁█▆ ▁▁█▁ - ▁▅▁▁▄▆▄▄▄▄▄▄▄▄▂▆█▄▃▃▃▃▇ ▅ ▁▃ - █▁▃▁▂█ ▅▂▂▂▂▂▂▁▃ ▇▃▃█▁▄▃ ▆▅ ▁▁ - ▃▆▃▃████████▇█ ▃▆▄▁▁▆▁ ▅▃ - ▇ ▃▇▅▆ ▂▅▇▅▁▅ - ▃▂▃▇▆▁▂ ▆▄▇▂▄▇█ - ▃▆▆▅█▁▇▃▄▄▆▅█▁█▂▆▅▇█ - ▂▇▇▁▂ ▄▂▂▂▂▁▇██ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_21.txt b/codex-rs/tui2/frames/hbars/frame_21.txt deleted file mode 100644 index b6a6c2c109c..00000000000 --- a/codex-rs/tui2/frames/hbars/frame_21.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▂▂▆▄▇▄▄▇▄▇▆▂▂ - ▆▇▇▂▂▂▂▇▁▄▁▇▄▂▂▆▇▇▂ - ▄▇▃▂▇▇▃█▂ ▂▃▁▇▇▂▂▇▆ - ▆▇▁▄██ ▂▆▄▇▆▄▇▆ - ▅▆▅▇▂ ▆▁▅▂▃ ▃▃▂▃▆ - ▅█▅▁ ▆▇▇▂▅▅ ▃▃▃▆ - ▁█▁▂ ▂ ▅▂▆▅█ ▃▄▃█ - ▂▃ ▃▄ █▁▆ ▁ ▁ - ▁ ▁ ▆▇▄▄▄▄▄▇▇▇ ▃▁▆█▁▆ ▄▅ ▁ - ▁ ▁▃ ▁▂ ▂▆▁ ▃█▂▇▁▆ ▅█▅▅ - ▄ █▅ ▂▂███ █▂ █▇▂▆▄ ▅▂ ▆ - █▇ ▅▄▂ ▂▇█ ▅ - ▇▂ ▃▆▂ ▂▅▄▇█ ▆▂ - ▇▄▂ ▇█▇▇▅▁▁▄▅▄▇██ ▆▂ - ▇▇▇▄▇▂▂▂▂▆▂▄▇▇▂ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_22.txt b/codex-rs/tui2/frames/hbars/frame_22.txt deleted file mode 100644 index 38195cd38b3..00000000000 --- a/codex-rs/tui2/frames/hbars/frame_22.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▂▆▇▄▇▄▄▇▄▇▆▂ - ▂▇▅▇▁▅▂▅▁▅▃▁▁▄▁▇▅▇▇▂ - ▆▄▇▄▁▃▇▇▂ ▂█▇▃▇▇▇▂▂▇▆ - ▄▇▁▇▇█ ▆▆▁▁▁▂▇▃ - ▃▁▁▅█ ▆▂▅▁▄▁▅▅▄█▁ - ▃▁▇▅▂ ▆▁▅▁▄▃▅▂ ▃▁▃▄▁ - ▁▇▇▃▅ ▁▇▁▃▇▅█ ▁▁▅▁ - ▁▁▄▁▂ ▁▃▁▁▃▃ ▁▃▁█▁ - ▁▁▆█▆ ▇▁▄▄▄▄▄▄▇▇▂▃▇▁▂ ▃▆ ▂▁▄█ - ▃▁▄ ▇▂▃▃▆ ▅▆█▁▅▅▁ ▃▇▄▂█▁▆▆ ▅▃▄ - ▃▃▃▂▁ █████▇▇█▂ ▂▅▇▇▂▅▅▅█▆▂ - ▃▇▃▂▃▆ ▂▇▆█▁▆█ - ██▇▄ ▇▇▂ ▂▅▇▆█▇▂▅ - █▅▇▇▆█▇▁▄▄▇▄▄▅▄▇███▆▇ - █▁█▁▂▄▂▂▂▆▄▂▅▄▇▂ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_23.txt b/codex-rs/tui2/frames/hbars/frame_23.txt deleted file mode 100644 index a81cac3ef20..00000000000 --- a/codex-rs/tui2/frames/hbars/frame_23.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▂▆▇▂▄▇▇▇▇▄▇▂▂ - ▂▄▁▇▁▃▂▆▂▄▄▆▁▂▁▇▁▇▂ - ▆█▇▁█▆▄▇▂ █▁▅▇▁▃▄▁▁▆ - ▅▄▁▇▄▅▂ ▂▄▄▃▁▁▄▃ - ▅▅▁▂▅█ ▆▅▆▆█▇▅▇▃▁▃ - ▆▃▁█▅▂ ▂▇▇▇██▅▇█▁▁▃▁▆ - ▂▁▁█▁ ▅▅▅▅ ▂▁▂ █ ▁█▇ - ▁▅▁ ▁ ▁█▁▆▂▃▆ ▁ ▇▅▁ - ▃█▃ ▁▇▁▄▄▄▄▄▄▄▄▁▂▇▁▂█▃▂ ▁ ▁▁▁ - ▁▃▃▂▁█▄█ ▅▂▃ ▃▁▂▃▆▁ ▃▂▁ - ▅█▁ ▁ ▂███████ █▂▅▄▄▁▂▅▃▅▂ - ▆▁▁▂▁▅ ▆▅▁▃▃▅▂ - ▃ ▁▆█▅▂▂ ▂▅█▄▇▅▇▅ - ▃▄▃▇▄▇▇▃▁▄▄▂▇▇█▆▂▇▇▂ - █▁ ▇▄▃▁▂▂▇▂▂▇▇▂ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_24.txt b/codex-rs/tui2/frames/hbars/frame_24.txt deleted file mode 100644 index 791f93b5914..00000000000 --- a/codex-rs/tui2/frames/hbars/frame_24.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▂▁▂▄▇▇▇▄▄▆▂ - ▆█▂▅█▁▂▂▁▄▄▇▆▂█▇ - ▆██▄▃▅▇█▁ ▅▂▅▇▁▇▇▆ ▃ - ▆█▅▅▆▅▁▄▆▃▂▁▅▂ ▂▇▃▁▃▂█ - ▆ ▅▅▃▅▆ ▂██▅▃▂▁▂▂▃▁▆█ - ▁▆▃▇▃▂ ▇▆▆▅ ▅▁▄ ▅█▃ - ▁ ▁▁ ▆▅▂▆▅█▅▃▃▁▃▃▁▃ - ▁ ▃▁▁ ▁▆▄▅▃▂▁▁▂▅▅▂▁▁ - ▁▅ ▁▄▄▄▅▇▇▁▁▁▇▇▂▇▆▂▁ ▁ ▁▁ - ▂ ▅ ▇▁▅▆▆▄▄▆▆▁▅▃▃▇▇▁ ▁▇▁ - ▃ █▃▁▃▇██████▂ ▃▆▅▇▃▁▄▅▆▂ - ▃ ▃▆▇▃ ▁ █▅▄▅ - ▃▅▇▃▂▁▅ ▂▄█▂▅▅▂▇ - ▅▆▇▆▃▃█▁▁▅▄▄▄▁▃▂▅▂ - ▇▅▂▇▇▄▃▂▂▄▄▇▇ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_25.txt b/codex-rs/tui2/frames/hbars/frame_25.txt deleted file mode 100644 index 565fdb82ead..00000000000 --- a/codex-rs/tui2/frames/hbars/frame_25.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▂▇▄▇▄▇▇▇▇▂ - ▆ ▇▆▇▂▂▄▃▁▇▁▁▆ - ▅▁▅▅▆▆█▅▁▇▃▄▃▁▁▁▂ - ▁▇▇▁ ▅ ▄▁ █▃▁▆▇▃▅ - ▆▂▂▃▆▅█▃▄▅▂▁█ ▁▅▅▃▅▇ - ▃▆▁▁▁▂▇▇▅▃▁▅ ▅▁▆▁▅▅▁▆ - ▁▅▁▇▁ ▁▂▂▅▃▂▅▁▃▅▂█▁▄▁ - ▁▁▁▅▁▅█ ▁▂▄▃▅▁▃▂▁▄▁▁▃ - ▃▇▃▁█▁▂▂▄▄▄▄▇▁▁▃▃▁▇▇▁▃ - ▅▆█▁▅▁▆▁▂▁▁▄▇▂▁▃▇▄▅▃▁ - ▃▁▇▅▃▁▂██████▇▅▁▃▁▅▁▂ - ▁▂█▁▄▇▄▃▆▆▇▅▂▅▆▅▅▁▅ - ▃▂▃▁▂▃▂▇▄▅▆▃▅▂▁▁▅ - ▃▅▄▃▃▂▇▄▁▇▇ ▇▁▇ - ▁▆█▇▃▇▆▂▇▁▇ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_26.txt b/codex-rs/tui2/frames/hbars/frame_26.txt deleted file mode 100644 index e37d671dc4b..00000000000 --- a/codex-rs/tui2/frames/hbars/frame_26.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▂▄▇▇▇█▇▆ - ▅▇▁▄▅▃▁▇▄▁▆ - ▅▇██▁▄▃▁▃██▃▆ - ▄▇█▆▁▁▁▂▁▁▂▇▅▃ - ▁▂▆▃▁▅▁█▇▃▅▄▁▃▄ - ▃ █▁▅▅▅█▇▂▃█▃▃▅ - ▁ ▇█▄▁▁▅▁▁▄▄█▇ - ▅▆ ▃▁▁▁▃▃▄▁▁▅▁▁ - ▁ ▅▃▇▄▇▁▇▄▄▅▃▁▁ - ▅▂▁▅▆▁▂▂▆▇▃▃▇▁ - ▁▂█▅▁▇█▃▆▅▂▇▃▁▁ - ▃▂▁▁▅▃ ▅▅█▁▁▂ - ▇ ▇▁▃ ▃▂▅▁▅▆ - ▁█▇▃▃█▇▂▇▇▄ - ▃▅▅▃▇▁▂▃▇ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_27.txt b/codex-rs/tui2/frames/hbars/frame_27.txt deleted file mode 100644 index d3dbefa9754..00000000000 --- a/codex-rs/tui2/frames/hbars/frame_27.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▄▇▇▇▇▆ - ▁▄▅▅▁▅▃ - ▄█▂ ▇▁▅█▁ - ▁▁▁▃▁▁▄▁▁ - ▁ ▂▁▂▆█▄ - ▁▂▅▂█▂▁▁▁▂ - ▄ █▁▁▂▇▁▁ - ▁ ▂▁▁▁▄█▁▁ - ▁▅ ▅ ▁▇▁▁▁▁ - █▄ ▅█▂▁▂▇▁▁ - ▁▆ ▂▃▇▇▇▅█ - ▁▁▇▇▁▁▂▅▁ - ▁▄▄▇▄▆▅▁▃ - ▃▂▂▁▃▄▅▅ - █▃█ ▃▃▅ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_28.txt b/codex-rs/tui2/frames/hbars/frame_28.txt deleted file mode 100644 index 0ae0f54e0b0..00000000000 --- a/codex-rs/tui2/frames/hbars/frame_28.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▅▇▇▄ - ▁▇▂▁▂ - ▄ ▄▁▂ - ▁▆▂▇▁ - ▁ ▁▁ - ▁ ▆█▁ - ▃▁▁ ▂▁ - ▁▆██▁ - ▅▃ ▂▁ - ▁▁▅▅▁▁ - ▁ ▂▁ - ▁▄▇▄▁ - ▁▄▇▁▁ - ▇▂▂▅▁ - ▁ ▅▇▁ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_29.txt b/codex-rs/tui2/frames/hbars/frame_29.txt deleted file mode 100644 index d333f278dce..00000000000 --- a/codex-rs/tui2/frames/hbars/frame_29.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▇▇▇▇▇▆ - ▆▁▇▅ ▇▃ - ▃▆▁▁ ██▁ - ▁▁▁▁▃▃▆▅ - ▃ ▁█▁ ▁ - ▁▆▁▁▁ ▂▂ - ▁▃▃▁▁▁ ▃ - ▁▁▄▁▁ - ▁▁▁▇▁ ▂ - ▂▁▄▁▁ ▂ - ▇▁▁▅▁▃ ▄ - ▁▅▁▁▁ ▂▁ - ▁▁▁▁▂▁▂▁ - ▁▄▁▇▅█▁█ - ▁▁▇ ▅▆ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_3.txt b/codex-rs/tui2/frames/hbars/frame_3.txt deleted file mode 100644 index 5d0b07202ae..00000000000 --- a/codex-rs/tui2/frames/hbars/frame_3.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▂▅▄▄▇▇▄▄▄▄▆▂ - ▂▇▆▅▃▁▁▇▁█▄▄▁▄▂▃▁▇▇▂ - ▆▄▅▇▄▁▇██▂ ▂█▁ ▇▇▇▅▃▁▆ - ▅▇▅▃▁▄▆▇▂ ▂▄▇ ▁▁▃ - █▅▇▁▅▃▅▁▁▇▃▂ ▃▃▃▁▁▂ - ▅▁▃▃▅ ▅▆▁▃▄▃▃ ▁ ▃▃▁ - ▄▇ ▁ █▇▃▁▂█▁▆ ▅▇▅▁▆ - ▆▆▁▆▁ ▆▆▁█▅▃▁ ▁▂▁▄▁ - ▆██▁ ▄▁▇▇▁▁▇▆▁▄▇▇▂▂▂▇▇▂▁▃█▄▁ - ▆▃▅▃▆ ▅▅▇▅▄▄▁ ▁▇▁▂▂▆ ▄▅▆▄▃▂▁▃▁▂ - █▃▁ ▁ █▁▁▇▆█ █▇▇▇███▇▇▆▅▅▅▆▅ - █▇▁ ▃▇ ▂ ▅▃▇▃▅▅ - ▄▃▅▅▇▃▆ ▆▇▄▅█▆▁█ - ▄▃▇▆▂▇▇▃▄▄▄▄▆▅▄▇▄▂▂▇█ - ▂▇▇▇▂▅▁▂▂▂▂▁▄▅▃█ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_30.txt b/codex-rs/tui2/frames/hbars/frame_30.txt deleted file mode 100644 index 7ceb36d37ac..00000000000 --- a/codex-rs/tui2/frames/hbars/frame_30.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▂▄█▇▇▇▆ - ▂▅▄▁▁▃▇█▄▃ - ██▃▁▆▃▃▁▇▆▃ - ▁▁▃▁▅▁▂▃▁▃ ▃▆ - ▄▇▁▁▂▃▁▆▁▆ ▁ - ▁▇▁▃▇ ▇▁▁▁▁▅▁ - ▃▅▁▁▁▃▂ ▃▅▃▁ ▄▂ - █▁ █▇▄▁▆▁█▁▆ ▂ - █▁▂█▃▁▄▁▃▅▁▆ ▂ - █▁▁▃▁▂▂▁▁ ▇▆ ▂ - ▃▁▁▄▃▂▇▁▁▁▇▁▁ - ▁▇▅▅▆▅▇▃▁▁█ ▁ - ▃▁▃▁▁▅▇▁▁▆▁▅ - ▆▃▆▇▄▃▅▁▆▅█ - ▃▆▅▁▃▂▂▄█ - ▂▂▂▂ \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_31.txt b/codex-rs/tui2/frames/hbars/frame_31.txt deleted file mode 100644 index 419be30ed96..00000000000 --- a/codex-rs/tui2/frames/hbars/frame_31.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▂▅▅▇▇▇▇▄▆ - ▂▇▇▄▇▆▂█▇▃█▇▆ - ▆▁▃▅▅▆▄▂▂▅█▁▂▇▃ - ▆▁▅▇▂▂▇▄▂▆▃▃▁▃▁▁▃ - ▃▁▁▁▁▇▆▇▃▂▆█▃▁▅▂▂ - ▄▁▃▂▁▂▁██▃▄▇▆▅▅▁▆█▁ - ▅▁▁ ▁▃▇▁▃▅▄▅▄▇▁▇▁▅▁ - ▃▃▆ ▁▁█▁▃ ▁▃██▁▁▃▅▁ - ▁▃▆▂▁█▃▁▁▁▅▇▁▁▁▂ ▁ - █▁▁▁▅▅▁▁▂▂▂▃▂▁▃▁▁ ▇ - ▃▇▂▅▇▁▆ ▅▇▇▇▂▅▁█▁▂ - ▁▁▁▃▃▃▆ ▂▄▇ ▁ - ▄▁▇▄▂ ▆ ▅ ▁▇▂▅▂ - ▅▁▃▁▅▂▁▂▆▁█▆▁▂ - ██▄▁▂▇▇▅▅▄▇ - ▂▂▂▂ \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_32.txt b/codex-rs/tui2/frames/hbars/frame_32.txt deleted file mode 100644 index 1234a419b0c..00000000000 --- a/codex-rs/tui2/frames/hbars/frame_32.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▆▇▇▇▇▄▄▁▆▂ - ▄▇▁▇▇▁▃▁▁▇▇▁▁▆ - ▅▃▇▄▃▆▇▃▅▆▂▇▁▇▃█▇▆ - █▅▅▁▂▆▂▄▆ ▆ ▆▂▃▁▇▆ ▆ - ▅▅▁▆▁▇▃█▇▁▄ █▂▆▁▃▅▃▇▃▆ - ▃▅▃▃▆▃▇▁▂▅▁▁▇▂ ▁▇▅ ▇ - ▇▁█▄▆▂█▃▁▁▆▆▁▆▄▂▅▂▅▅▁█▂ - ▁▁▁▆▂ █▂▁▁█▅█▁▄▆▂█▃▁██▂ - ▃▁▁▆▂ █▂▁█▂▂▁▇▇▇▁▁▁▄▁█▄▂ - ███▂▂▁▇▃█▃▂▂▂▂▂▁▁▇▁▄▁▆▂ - ▆▃▇▁▁▅ ▁▇██▇▇▇▃▄▅▅▅▅▅ - ▃▆▁▃▃▃ ▆▃▁▅█▁ - █▇▇▁▁▁▇▂ ▂▅▂▁▅ ▆ - ▃▆▃█▁▇▁▄▄▇▂▂▇█▂▅ - ▁▄▄▂▂▆▇▅▇▂██ - ▂▂▂ \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_33.txt b/codex-rs/tui2/frames/hbars/frame_33.txt deleted file mode 100644 index 780eb104ef3..00000000000 --- a/codex-rs/tui2/frames/hbars/frame_33.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▂▆▇▄▇▄▇▄▁▅▂ - ▂▇▇▁▁▃▇▄▁▅▁▆▁▇▁▇▅ - ▂▅▃▁▇▇▇▆▄█▂ █▇▅▁▁▇█▆ - ▆▃▅▁▅▂▁▆▂ ▇▁▁▃▂▇ - ▂▅▁▅▂▁▁▅▃▃▃ ▁▇▃▃▃ - ▅▅▆ ▃▂▃▃▂▃▆ ▅▃▅▂▃ - ▁▁▇ ▃▆▇▁▂▁▃ ▁▇▁▁ - ▇▁▆▆ ▆██▃▅▄▆ ▃█▁▇ - ▁▁▁ ▁ ▆▂ ▅▇▆▆▆▄▄▄▇▁▁▇▅▂▁▃▁ - ▃▅▁██▅▅▆▅▂▁▁▂▂▂▂▂▁▁▇▃▃▃▁▁▅ - ▃▄▃▆ ▁▅▁▅ ▅ ████▇▇▇ ▁▁▆▅▃▂ - ▃▇▃▇▆█▆ ▅▅▄▅▄▅ - ▇▃█▇▆▇▄▆ ▂▇▁▆▅▇▃█ - ▃▁█▇▃▇▁▆▄▇▂█▄▆▃▇▁▇ - █▄▄▇▆▂▂▂▃▇▇▄▆█ - ▂▂▂▂ \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_34.txt b/codex-rs/tui2/frames/hbars/frame_34.txt deleted file mode 100644 index 4bf69e69eb4..00000000000 --- a/codex-rs/tui2/frames/hbars/frame_34.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▂▆▄▇▇▇▇▄▁▆▂ - ▂▇▇▇▅▃▄▁█▁▇▇▄▇▇▄▆▄▂ - ▂▅▅▆▁▇█▅▁▇▂ ▂▃▃▃▆█▇▇▆ - ▆▅▂▁▇▂▅▆▂ ▇▁▆▇▃▃ - ▅▅▁▃▆▅▁ ▁█ ▆ ▃▃▇▃▃ - ▆▅▃▁▂▅ ▃▃█▁▃▇▃ ▃▆▃▁▃ - ▁▇▃▁▂ █▃▂▇▁▂▃ ▁▄▁▂ - ▃▁▄█▅ ▁▇▂▁▅█▁ ▅ ▂▃ - ▆█▅▅█ ▅▃▅▅█▂▄▄▇▇▇▇▇▇▁▂▂█ ▁▃▂ - ▁▁▃▆▅▆▆▃█▅▅█▁▁▁▂▁▅▂▂▂▆▅▂▁▁▂▁▃ - ▅▂▃▆ ▁▃▁▇▃ █▇▇▂▂▂▂▃ ▆▅▆▃▁█ - ▅▆▃▄▄▃▂ ▅▄▅▁▅█ - ▃▇▂▁▇▃▄▆ ▂▇▄█▂▅▆▂ - ▆▃▅▁█▁▇▆▄▆▇▄▇▄▇▂▇▇▅█ - █▁▃▄▄▂▂▂▂▂▄▄▇▄▃ - ▂▂▂ \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_35.txt b/codex-rs/tui2/frames/hbars/frame_35.txt deleted file mode 100644 index 86dde2ad341..00000000000 --- a/codex-rs/tui2/frames/hbars/frame_35.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▂▆▇▄▄▇▇▇▄▇▆▂ - ▆▇▄▇▅▁▇▇▇█▁▇▁▅█▁▇▄▂ - ▂▅▅▁▄▇▇▁▃▇█▂ ▂█▇▄▇▅█▁▇▆ - ▆▅▅▇▅▆▄▆▂ ▇▇▂▃▁▂ - ▅▅▁▁▅▅▃ █▃▇▆ ▁▃▇▁▆ - ▄▅▄▅▇▂ █▁▂█▅▅▇▂ ▁▃▃▁ - ▃ ▅▅ ▃▃ █▇▂▅ ▁▃▃▃ - ▁▁▁▅ ▅▃▃▄▅█ ▅ █▅ - ▅ ▆▂ ▆▁▁▅▄█▅▁▄▄▂▄▂▄▇▁▁ ▁▁▄▇ - ▁▆▃▄█ ▅▅▄█▇▇▅▁▄ ▃▆▂▂▂▂▅█▁▅█ ▁█ - ▅▂▃▆█▅▃▂▁▅▃▂ ▂█▃▃▃▃▇▃▃▇▅▅▂▆▁ - █▅▃▃▅▁ ▂▅▇ ▆▃ - ▃▄▂▃▄▆▅▆ ▆▄▅▂▇▇█ - █▃▂█▁▇▁▆▅▁▁▇▁▄▅▇█▂▅▄█ - █▃▁▄▄▂▂▅▂▂▇▇▇█▃ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_36.txt b/codex-rs/tui2/frames/hbars/frame_36.txt deleted file mode 100644 index bccadcf7b78..00000000000 --- a/codex-rs/tui2/frames/hbars/frame_36.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▂▂▇▄▇▇▇▇▄▇▆▂ - ▂▄██▃▇▅▄▅▇▃▇▄▇▇█▇▇▇▂ - ▆▃█▃▆▇▇▆▇█▂▂ ▂█▇▁▇▇▁▆▇▁▆ - ▂▁█▇▇▂▂▂▂ █▇▁▃█▁ - ▆▅▂▄▃█▁▃▂▃▆▅ ▃▃▅▇ - ▅ ▆▅▂ ▇▂ █▁▅▆ █▃▃▇ - ▁ ▄ ▂ ▃ █▃▃ ▁▃▁▆ - ▁▁▃▁ ▂▃ ▂▅▅ ▃▁ ▁ - ▁██▁ ▅▅ ▆▇██▆▇▇▂▇▇▇▇▇▂ ▁▃▁▁ - ▂▇▁▃ ▆▁▂▂▅▁▅ ▂▁▂▂▂▂▂▂▂▁▁ ▃▅█▂ - █ ▇▁▃ ▇▂▇▅▃ █▇▇▃▃▃▃▃█ ▁▆█▅ - █▆█▃▄▅ ▆▅▁█▅ - ▃▅▁▁▇▁▆ ▆▇▇█▅▆▂ - ▇▆▂█▇▇▄▃▄▁▂▇▁▄█▆█▆▄▇█ - ▂▇▇▅▁▂▆▆▂▆▆▇▇▇▇█ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_4.txt b/codex-rs/tui2/frames/hbars/frame_4.txt deleted file mode 100644 index 5867215a96d..00000000000 --- a/codex-rs/tui2/frames/hbars/frame_4.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▂▅▄▄▇▄▄▇▄▇▆▂ - ▂▆█▇▇▁▅ ▁▃▄▅▇▄▁█▅▇▆▂ - ▆▇▄▁▁▁▇▇█▂ ▂▃▁▃▃▇▁▅▃▃▂ - ▅▅▁▆▁█▅▇▆ █▁▇▇▃▇▃ - ▅▁▅▂▁▄▁▃▄▃▁▆ ▃█▃▁▇▃ - ▅▁▁▃▁ █▂▁▁▅▇▃ █ ▃▄▁▆ - █▃▁▄▂ █▇▇▃▇█▁▆ ▆ █▄▁ - ▁▅▃ ▆▁▅▇▃▁▁ ▁▁▁ - ▂▁ ▁▂ ▄▁▅█▃▅▅▇▁▇▇▇▂▂▂▂▆▆ ▁▁▇ - ▁▃▅ ▁ ▆▅▅▅▆▂▁▂▄▃▁▆▅▂▆▅▅▃▄▁▅▅█▁ - ▂▁▄█▃▆▃▃▆▃▅ █▇▇▇▇▇▇▇█▃▆▁▁▁▂ - ▆▁▃█▁▂▂ ▆▃▄▅▇▇█ - ▃▃▆▅▃ ▆▂ ▂▅▃▆▇▄▁▅ - ▁▁▁▄▂▇▇▃▄▄▄▄▆▇▄▇▇▃▁▇▂ - ▇▃▃▁▅▁▂▂▂▂▂▄▆▇▂ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_5.txt b/codex-rs/tui2/frames/hbars/frame_5.txt deleted file mode 100644 index d0cd750b8a7..00000000000 --- a/codex-rs/tui2/frames/hbars/frame_5.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▂▅▄▄▄▇▄▄▄▇▆▂ - ▂▇█▇▅▇▅▁▁▇▁▄▇▄▂▁█▇▆ - ▇▂▅▃▇▁▇█▂ █▃▄▁▇▃ ▆▇▆ - ▆▁▅▁▅▁▅▄▆ ▆▃▁▁▁▁▂ - ▆▃▅▇▅▃▂▇▁▂▃▆ ▂ ▃▁▃▁▂ - ▁▁▁▁▅ ▃▂▁▃▄▅▁▂ ▇▅▁▁ - ▁▅▁ ▇ ▂▃▂▁▃█▁▆ ██▁▇▁▆ - ▁▅▁▂█ ▆▂▄▁▅▂▁ ▇▁▂▁ - ▁▆▁▃▇ ▅▂▁█▆▁▇▄▂▁▇▁▂▂▂▇▆▂█▁▅▁ - ▂▃▁▃▆▆▅▇▇█ ▁█ ▅▅▃▄▃▆▄▅▆▅▁▅▇▁▂ - ▃▆▁▁▆▁▆▃▃▅▇▂ █▇▇█████▆▁▄▁▃▅ - ▃▃▇▅▃▃ ▂ ▅▂▅▇▆▃ - ▃▅▇▃▁▄▇▂ ▂▄▇▆▇█▄▇ - ▃▁▇▁▆▇▇▃▄▁▄▂▆▇▄▇▃▁▄▇ - ▃▆▇▂▇█▂▂▂▆▂▆▇▇▂ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_6.txt b/codex-rs/tui2/frames/hbars/frame_6.txt deleted file mode 100644 index 2fde73afab1..00000000000 --- a/codex-rs/tui2/frames/hbars/frame_6.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▂▅▄▄▄▇▄▄▇▇▂▂ - ▆▃▄▅▇▁▄▇▇▁▇▇▄▂▆▇▇▂ - ▆▆▆▁▁▁▇█ ▂█▁▅▄▇▇▇▅▃▂ - ▅▆▁▇▁▇▂▄▆ ▃▇▁▁▁▁▆ - █▄▁▁▁▇▃▃▇▇▃ ▅▃▃▁▃▆ - ▄▆▅▁▅▂▃▇▄▁▃▂▁▆ ▅▄▃▅▁ - ▂ ▇▁▁ ▃▃▁▅▃▃ ▃▇▁▂▁ - ▁ ▁▁▁ ▂▁▄▁▅▁▁ ▁▇▃▁▁ - ▂▅▁▆▁ ▅█▁▁▇▄▅▇▂▁▇▁▂▂▇▇▂▅▃▅▁ - ▁▁▃▆▅▂▃▂▄▇ ▁▅▄▇▆▁▁▆▄▄▄▃▁▆▁▃ - ▅ ▁ ▅▁▁▁▄▅█ █▇████▇█▂▃▃▅▁ - ▆ ▄ ▅▆▂ ▆▅▂▅▅▁▂ - ▅▂▃▂▇▇▆ ▂▇▃▂▅▇▆▇ - ▂█▁▇▆█▇ ▄▁▂▂▆▇▇▇ ▅▄█ - ▃▆▄▇▇▆▂▂▂▂▂▆▇▇ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_7.txt b/codex-rs/tui2/frames/hbars/frame_7.txt deleted file mode 100644 index f9b4ed92190..00000000000 --- a/codex-rs/tui2/frames/hbars/frame_7.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▂▅▁▄▇▇▄▄▇▆▂ - ▆▇▂▇▃▁▇▇▇▄▄▃▄ ▂▇▆ - ▇ ▆▁▁▁▇▂ ▂▃▁▂ ▇▅▂▇▆ - ▃▃▁▁▁▁▄▇ ▃▁▃▇▁▅▆ - ▂█▁▇▁▁▃▁▅▃▆ ▅ ▇▁▁▃ - ▁ ▄▁▁█ ▂█▃▄▁▃ ▆▆▁▁▁ - ▂▃█▁ ▃▂▁▃▁▃▁ ▇▇▃▁▁ - ▂█▅▁▁ ▄▇▅▁▇▁▂ █ ▁▁▁ - ▅█▁▇ ▆█▂▅▁▃▅▁▁▄▄▂▁▁▄▄▇▃▁▁ - ▁▆█▁▁▄▅▁▁▅█▁▅▃▁▁▃▃▆▄▆▄▃▁▃▁ - ▃ ▄▃▅▇▁▇▂▅ ▇▇▇▇▇▇██▂▅▁▁ - █▂ ▅▂▅▂▂ ▆█▆▅▄▅█ - █▄▆▇▃▃▃▂ ▆▃▁▆▇▅▇█ - ▃▂█▃▁▇▃▄▁▂▂▂▇▇▁▇▇▅ - █▁▇█▁▅▁▂▂▆▂▄▇▇ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_8.txt b/codex-rs/tui2/frames/hbars/frame_8.txt deleted file mode 100644 index 44c448de8a3..00000000000 --- a/codex-rs/tui2/frames/hbars/frame_8.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▂▂▇▂▄▇▇▄▇▆▂ - ▆█▇▅▁▁▁▇▄▄ ▁ █▇ - ▂▁▄▆▁▅▅▇ ▆█▁▅▁▇▃▇▃▆ - ▂▇▃▇▅▁▆▇▁ █ ▄▃▁▁▃ - █ ▅▁▅▆▃▁▅▃ ▅█▃▁▁▆ - ▁█▆▁▃▃▃▂▃▁▂▇ ▃▇▁▁▁ - ▁▇▁▁▁ █▂▄▃▁▂▁ ▁▃▁▅▁▆ - ▃▆▁▁▅ ▁▇▃▅▁▆▇▅ ▅▂▅▁▁ - ▃ ▃▁▁▂▃ ▅▇▄▁▁▅▇▆▇▆▆▃▁▁▁ - ▃▇▅▄▂▁█▆▁▆▁▁▄▁▄▂▂▁▁▄█ - ▅▅▃▃▁▃▁▁▁▅█▃▇▃▇█▃▇█▅▃▁ - █▆█▁▃▁▂▂ ▅▆▅▅▅█ - █▃▂▇▃▃▃▂ ▂▅▃▂▁▇▅▇ - ▅▅█▁▆▇ ▄▄▇▇▄▅▄▆▁▂ - ▁▁▂▇▇▃▁▂▆▇▅▄▂ - ▂▂ \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_9.txt b/codex-rs/tui2/frames/hbars/frame_9.txt deleted file mode 100644 index a18a8a231c3..00000000000 --- a/codex-rs/tui2/frames/hbars/frame_9.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▅▄▃▇▅▇▇▄▆ - ▅▇▂▅▁▁▇█▄ ▂▆▃▂ - ▅ ▆▁▁▅▇▃▃▄▅ ▃▂▁▆ - █ ▁▁▅▄▄▂ ▂▃▆▁▃▃▁▇▆ - ▅▇█▄▃▇█▁▇▆ ▇▅█▇▅▇▁▁ - ▁ ▁▁▁█▃▁▃▄▂▂ ▁█▁▇▁▁▁ - ▂ ▁▇▃▁▇▇▁▃▃▆█▅▆▅ ▂▁▁ - ▁ ▁▅▂▆▃▁▁▁▂▅▄▃▂▁ ▁▁ - █▆▁█ ▅▁█▁▁▁▁▇▁▁▆▁▁▂ - ▁▂▁▁▁▂▆▁▃▁▂▁▁▁▁▂▂▁▁▁ - █ ▂▃▅▃▃▁▅█▇▇▇▇▁▁█▅▃▁ - ▃▁▁▁▃▆▂ ▆▅ ▃▅▁▂ - ▃▇▃▁▃▃ ▅▄▂▄▁▅▇ - ▃▆█▃▂▁▇▅▇▄▄▄▇▁▂ - █▆▂ ▇▃▃▁▄▇▅█ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_1.txt b/codex-rs/tui2/frames/openai/frame_1.txt deleted file mode 100644 index 1019a11c958..00000000000 --- a/codex-rs/tui2/frames/openai/frame_1.txt +++ /dev/null @@ -1,17 +0,0 @@ - - aeaenppnnppa - anpeonpepnniina aopa - pioipoooaa aooaoiniiip - noanooppa eoinip - naneoainann oeinnp - io pa ioeniip oeniip - paopo onioeoia iei - iiaia peeinio oai - inioa niianioeippppppapp no i - aino anpa eo ioiaaaeepppiepepi - naaoa anpeea aaoooo aooepnae - oap op a poeoai - anpanpa anapiapo - aopna opennnnenopapio - aoooiiiaaapanpoo - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_10.txt b/codex-rs/tui2/frames/openai/frame_10.txt deleted file mode 100644 index 942f59e944f..00000000000 --- a/codex-rs/tui2/frames/openai/frame_10.txt +++ /dev/null @@ -1,17 +0,0 @@ - - apooonppa - ooapopnieaop - aapiieiipipnpp - iaaeninaenpoonnp - e niioia opeaeie - ia oioieen aaoi - n ioooiiiio iep - o iinpepiipaaenii - o in nniniipnpnii - i pinpiaeaoaaaioa - p oiiiiioo nponia - naopnpoo aioopee - io iiiiopepeiea - naooeipna eeo - op aonapno - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_11.txt b/codex-rs/tui2/frames/openai/frame_11.txt deleted file mode 100644 index ef0aff76e0f..00000000000 --- a/codex-rs/tui2/frames/openai/frame_11.txt +++ /dev/null @@ -1,17 +0,0 @@ - - pooooppa - eo niiinnp - eoaaiinnoenp - iaaiinnpanee - aoennipnonaiip - ipaiipanniaeii - oo piiiiiiainn - i iieiine ioi - e oieiioepiai - oo aienpoeiaia - oap iiiineinni - napeiaoeoonia - a oinnaaino - np oiipaeoa - na oopapa - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_12.txt b/codex-rs/tui2/frames/openai/frame_12.txt deleted file mode 100644 index 8940e05bd67..00000000000 --- a/codex-rs/tui2/frames/openai/frame_12.txt +++ /dev/null @@ -1,17 +0,0 @@ - - pooope - pnaaeipn - o ienpp - epiiinnoi - i oiiiieei - ioeeoaoaio - i ieniin - i epniio - op ieiipii - i eionoa - ie ionooe - p oi api - e ooiponi - oaeopinia - n oinee - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_13.txt b/codex-rs/tui2/frames/openai/frame_13.txt deleted file mode 100644 index c73afab740d..00000000000 --- a/codex-rs/tui2/frames/openai/frame_13.txt +++ /dev/null @@ -1,17 +0,0 @@ - - eooop - iaaii - iaaio - iooia - o ii - oepii - p ii - p oi - ep aie - io pni - oaee ia - iaaai - ippia - oaain - p eon - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_14.txt b/codex-rs/tui2/frames/openai/frame_14.txt deleted file mode 100644 index 8a273a1666a..00000000000 --- a/codex-rs/tui2/frames/openai/frame_14.txt +++ /dev/null @@ -1,17 +0,0 @@ - - pooon - peaaen - iaiie i - iieioaip - iiin i - ioii oi - iiii e o - iinia n - iiii o - oaee ne - nioopepi - ioiiaaai - e oinnna - ineoaae - ao one - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_15.txt b/codex-rs/tui2/frames/openai/frame_15.txt deleted file mode 100644 index 5a0e8f1b549..00000000000 --- a/codex-rs/tui2/frames/openai/frame_15.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ppoooia - apniiaeop - eeionniooi - iea eepeaai - iinonniieoai - niipiieei o - ii iiiiiipi - iieiipiii opi - iipppoiiiaeei - iaineinioe ei - ieiiiiai a n - iooeaiinp n - oinp eeea na - iaiinnepne - naoaaaae - aa \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_16.txt b/codex-rs/tui2/frames/openai/frame_16.txt deleted file mode 100644 index 06c519f6028..00000000000 --- a/codex-rs/tui2/frames/openai/frame_16.txt +++ /dev/null @@ -1,17 +0,0 @@ - - anpooopia - eaiioiininn - ieeonennii on - eee noaniniiooi - iinaeooniioeian - iiioio inn i iei - nni eapiiiieoop i - ien iaiieiiion ai - nniiipieaiioop e - ooaapnnnoieai pai - iiipooeoninoneiia - iio ia aeep e - nio i epeapi - niioaoieepai - oeeaneaano - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_17.txt b/codex-rs/tui2/frames/openai/frame_17.txt deleted file mode 100644 index 0bd4ef6dfc5..00000000000 --- a/codex-rs/tui2/frames/openai/frame_17.txt +++ /dev/null @@ -1,17 +0,0 @@ - - pnpppnnipa - anooiiionoipoap - poneannappoiiiaon - eieaeioaa oinniiinn - iea i ioennnee p - anopea peei epneiee - o nei peeia ooiieni - poni eeiieipiinno - peoennnpinainaeniii i - ioanaaieoneiin npopn - nnneooooooonaioeen no - niiao aeiia n - ann ona popeaae - p iopnnpaanoeio - apneappioapo - a \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_18.txt b/codex-rs/tui2/frames/openai/frame_18.txt deleted file mode 100644 index de59f344efe..00000000000 --- a/codex-rs/tui2/frames/openai/frame_18.txt +++ /dev/null @@ -1,17 +0,0 @@ - - annpnnpnipa - apoanpppiaapopnna - aeeiooonopoeaopeen o - piioepaoo onaaeninnnnnp - anee ei aopaneieiannnna - eni ppieaaaepopiopeaeaei - iinee peoeioaeooininp - onpii anoiina inio - noo pnnnnnnpiipioenp ioia - anniono iieaanaiiianian p - ieinaoooooooa naaeieoeapa - nn pnp aaooeoao - naopaae aoaaenpa - oieooppnnnaooaeapo - oiiaeaapeponpo - a \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_19.txt b/codex-rs/tui2/frames/openai/frame_19.txt deleted file mode 100644 index ade56623593..00000000000 --- a/codex-rs/tui2/frames/openai/frame_19.txt +++ /dev/null @@ -1,17 +0,0 @@ - - aannpipnniea - apiiinennnaoapiopp - ponneoipoa aoepaonn - eniopaa annppaiop - iieaea piaoeoin inp - ineoo aioie ee oeoi - aniio eoaeippa eeepi - iiii inoniop i iii - ioinpnnnnnniiiiponoin n oii - anepinoeaopnpap aa enanpeeia - onoepioooooooo anaeiee eeo - nnaoan aeeaeeo - o aniiop pnneeina - ooeioeipaanneoo noio - oieaepoapeaopa - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_2.txt b/codex-rs/tui2/frames/openai/frame_2.txt deleted file mode 100644 index be49360bbf5..00000000000 --- a/codex-rs/tui2/frames/openai/frame_2.txt +++ /dev/null @@ -1,17 +0,0 @@ - - aeaenpnnnppa - appeonpiiiniinaoiopa - pioinooioaa aoonoeiinip - aieaeooppa ppnieip - paoeoainanna apinip - oepoa ionpenp nnioi - piooi aeiniaip eoioi - io na pioiino ioeei - oooni niaaaioeipppppppppiaiio - anoi eiio eo innaaaaeeaaipe i - nnn o oneeio aoooooooooipoii - onaeep a ppnepo - enaaipp pnap aio - aianaaoopennnnenoaaiio - aopopiiaaaaineoo - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_20.txt b/codex-rs/tui2/frames/openai/frame_20.txt deleted file mode 100644 index 6eaf358e88d..00000000000 --- a/codex-rs/tui2/frames/openai/frame_20.txt +++ /dev/null @@ -1,17 +0,0 @@ - - aapniinpnpaa - pnnonpapnnniiaaonpp - ppoapooioo aoaonpoipa - aioaoio appineonn - ai pio ae aiiaoniin - eapio poonioe oniin - ninpi po epoa pnie - iaaea ooneiop iioi - ieiinpnnnnnnnnaponaanno e in - oiaiao eaaaaaain onnoinn pe ii - npanoooooooooo npniipi en - o aoep aeoeie - naaopia pnoanoo - appeoipannpeoioapeoo - aopia naaaaiooo - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_21.txt b/codex-rs/tui2/frames/openai/frame_21.txt deleted file mode 100644 index 5f317f375c5..00000000000 --- a/codex-rs/tui2/frames/openai/frame_21.txt +++ /dev/null @@ -1,17 +0,0 @@ - - aapnpnnpnppaa - ppoaaaapinipnaapopa - noaapoaoa aaiopaaop - poinoo apnopnop - epeoa piean nnaap - eoei pooaee nnap - ioia a eapeo nnno - an nn oip i i - i i ppnnnnnppp aipoip ne i - i in ia api aoaoip eoee - n oe aaooo oa ooapn ea p - op ena aoo e - oa apa aenoo pa - ona ooopeiinenooo pa - oppnpaaaapanpoa - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_22.txt b/codex-rs/tui2/frames/openai/frame_22.txt deleted file mode 100644 index 74b75b91135..00000000000 --- a/codex-rs/tui2/frames/openai/frame_22.txt +++ /dev/null @@ -1,17 +0,0 @@ - - appnpnnpnppa - apeoieaeieniinipeopa - pnoninooa aoonoopaapp - noiooo ppiiiaon - niieo paeinieenoi - nioea pieinaea ninni - ipone ipinoeo iiei - iinia iniian iaioi - iipop pinnnnnnppanoia np aino - ain oannp epoieei nonaoipp enn - nnnai ooooooooa aeopaeeeopa - nonanp aopoipo - oopn ppa aeopooae - oeoppooinnpnnenoooopo - oioianaaapnaenoa - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_23.txt b/codex-rs/tui2/frames/openai/frame_23.txt deleted file mode 100644 index 35e7fe2210d..00000000000 --- a/codex-rs/tui2/frames/openai/frame_23.txt +++ /dev/null @@ -1,17 +0,0 @@ - - appanpoppnpaa - anipinapannpiaioipa - popiopnoa oiepinniip - enionea annniinn - eeiaeo peppooeonin - pnioea aoooooeooiinip - aiioi eeee aia o ioo - iei i ioipanp i oei - aon ipinnnnnnnniaoiaona i iii - innaiono ean nianpi nai - eoi i aooooooo oaenniaeaea - piiaie peinaea - n ipoeaa aeonoepe - nnnpnopninnappopapoa - oi onniaapaaooa - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_24.txt b/codex-rs/tui2/frames/openai/frame_24.txt deleted file mode 100644 index a74ea1f0bb7..00000000000 --- a/codex-rs/tui2/frames/openai/frame_24.txt +++ /dev/null @@ -1,17 +0,0 @@ - - aianpponnpa - poaeoiaainnppaop - poonnepoi eaeoioop n - poeepeinpnaiea aoninao - p eenep aooenaiaanipo - ipnpna oppe ein eon - i ii peapeoeaninaia - i nii ipnenaiiaeeaii - ie innneppiiippaopai i ii - a e oieppnnppieanpoi ioi - n oninoooooooa npeoninepa - n npon i oene - neonaie anoaeeao - epopnaoiiennniaaea - oeaopnnaannpo - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_25.txt b/codex-rs/tui2/frames/openai/frame_25.txt deleted file mode 100644 index c2c5b30b296..00000000000 --- a/codex-rs/tui2/frames/openai/frame_25.txt +++ /dev/null @@ -1,17 +0,0 @@ - - apnonppppa - p opoaannioiip - eieeppoeipnnniiia - ipoi e ni onipone - paanpeoaneaio ieeneo - npiiiaopeaie eipieeip - ieioi iaaenaeineaoini - iiieieo ianneinainiin - apnioiaannnnpiinnipoin - epoieipiaiinoainoneni - nioeniaoooooopeinieia - iaoinpnnppoeaepeeie - aaaianaonepaeaiie - nennnaonioo oio - ipoonppapio - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_26.txt b/codex-rs/tui2/frames/openai/frame_26.txt deleted file mode 100644 index 09a947d35d6..00000000000 --- a/codex-rs/tui2/frames/openai/frame_26.txt +++ /dev/null @@ -1,17 +0,0 @@ - - anoooopp - eoinenipnip - eoooinninoonp - noopiiiaiiapea - iapnieiooneninn - n oieeeooanonne - i ooniieiinnop - ep niiiaaniieii - i enonpipnnenii - eaiepiaaponnoi - iaoeioonpeapnii - naiien eeoiia - o oin aaeiep - ioonnooaoon - neeaoiano - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_27.txt b/codex-rs/tui2/frames/openai/frame_27.txt deleted file mode 100644 index b3fef11ac8c..00000000000 --- a/codex-rs/tui2/frames/openai/frame_27.txt +++ /dev/null @@ -1,17 +0,0 @@ - - nooopp - ineeien - noa oieoi - iiiaiinii - i aiapon - iaeaoaiiia - n oiiaoii - i aiiinoii - ie e ipiiii - on eoaiaoii - ip aaoopeo - iippiiaei - innpnpeia - naainnee - ono ane - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_28.txt b/codex-rs/tui2/frames/openai/frame_28.txt deleted file mode 100644 index 11fdcec5207..00000000000 --- a/codex-rs/tui2/frames/openai/frame_28.txt +++ /dev/null @@ -1,17 +0,0 @@ - - eoon - ipaia - n nia - ipaoi - i ii - i poi - aii ai - ipooi - en ai - iieeii - i ai - inpni - inoii - oaaei - i epi - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_29.txt b/codex-rs/tui2/frames/openai/frame_29.txt deleted file mode 100644 index 2dc6c667532..00000000000 --- a/codex-rs/tui2/frames/openai/frame_29.txt +++ /dev/null @@ -1,17 +0,0 @@ - - poooop - pioe on - apii ooi - iiiianpe - n ioi i - ipiii aa - inniii a - iinii - iiioi a - ainii a - oiieia n - ieiii ai - iiiiaiai - inioeoio - iio ep - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_3.txt b/codex-rs/tui2/frames/openai/frame_3.txt deleted file mode 100644 index 9026d59a430..00000000000 --- a/codex-rs/tui2/frames/openai/frame_3.txt +++ /dev/null @@ -1,17 +0,0 @@ - - aennppnnnnpa - appeniipionninaaiopa - pneonioooa aoi oopeaip - epeninppa ano iin - oeoieaeiiona naniia - einne epinnnn i nni - no i ooniaoip eoeip - ppipi ppioeni iaini - pooi niopiiopinppaaappaiaoni - pnenp eeoenni ioiaap nepnnainia - oai i oiippo ooooooooopeeepe - ooi np a eapaee - naeeonp ppneopio - nappaooannnnpenonaapo - aopoaeiaaaaineao - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_30.txt b/codex-rs/tui2/frames/openai/frame_30.txt deleted file mode 100644 index 73b4906d0ec..00000000000 --- a/codex-rs/tui2/frames/openai/frame_30.txt +++ /dev/null @@ -1,17 +0,0 @@ - - anooopp - aeniinoonn - ooaipnnippn - iinieianin np - noiianipip i - ioiap oiiiiei - neiiina neni na - oi opnipioip a - oiaoninineip a - oiiniaaii pp a - niinnaoiiipii - ioeepeoniio i - niniieoiipie - pappnaeipeo - npeiaaano - aaaa \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_31.txt b/codex-rs/tui2/frames/openai/frame_31.txt deleted file mode 100644 index cc71fce9200..00000000000 --- a/codex-rs/tui2/frames/openai/frame_31.txt +++ /dev/null @@ -1,17 +0,0 @@ - - aeeopopnp - aponppaopnoop - pineepnaaeoiaon - piepaaonapnniniin - aiiiipponaponieaa - niaaiaioonnopeeipoi - eii iaoinenenoioiei - nnp iioin iaooiinei - inpaioaiiiepiiia i - oiiieeiiaaanainii o - aoaepip eoooaeioia - iiinnnp ano i - niona p e ioaea - einieaiapiopia - ooniapoeeno - aaaa \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_32.txt b/codex-rs/tui2/frames/openai/frame_32.txt deleted file mode 100644 index c0d6573da78..00000000000 --- a/codex-rs/tui2/frames/openai/frame_32.txt +++ /dev/null @@ -1,17 +0,0 @@ - - pppppnnipa - noiooiniiooiip - eaonappaepaoionoop - oeeiapanp p panipp p - eeipionooin oapinenonp - nennpnoiaeiipa ipe o - oionpaoniippipnaeaeeioa - iiipa oaiioeoinpaoniooa - niipa oaioaaipppiiiniona - oooaaiononaaaaaiioinipa - pnoiie iooooooaneeeee - npiann pnieoi - oooiiipa aeaie p - npnoipinnoaapoae - innaappeoaoo - aaa \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_33.txt b/codex-rs/tui2/frames/openai/frame_33.txt deleted file mode 100644 index 56ef96d36a8..00000000000 --- a/codex-rs/tui2/frames/openai/frame_33.txt +++ /dev/null @@ -1,17 +0,0 @@ - - appnpnpniea - apoiinonieipioioe - aenipoppnoa ooeiipop - pneieaipa oiinao - aeieaiienan ipnna - eep nannanp enean - iip npoiain ioii - oipp poonenp noio - iii i pa eopppnnnpiipeaini - aeiooeepeaiiaaaaaiioaaaiie - nnnp ieie e ooooooo iipeaa - npnopop eenene - onooponp apipeono - nioonpipnpaonpnoio - onnppaaaaponpo - aaaa \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_34.txt b/codex-rs/tui2/frames/openai/frame_34.txt deleted file mode 100644 index b6e87c62f1c..00000000000 --- a/codex-rs/tui2/frames/openai/frame_34.txt +++ /dev/null @@ -1,17 +0,0 @@ - - apnppppnipa - apopennioiponoonpna - aeepiooeioa aannpooop - peaioaepa oiponn - eeinpei io p nnona - peniae nnoinon npnin - ioaia onaoiaa inia - ninoe ioaieoi e an - poeeo eneeoannppppppiaao iaa - iinpeppaoeeoiiiaieaaapeaiiain - eanp inioa oooaaaaa pepnio - epnnnna eneieo - npaipnnp apnoaepa - pneioippnppnonoapoeo - oinnnaaaaannona - aaa \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_35.txt b/codex-rs/tui2/frames/openai/frame_35.txt deleted file mode 100644 index 899d6766b79..00000000000 --- a/codex-rs/tui2/frames/openai/frame_35.txt +++ /dev/null @@ -1,17 +0,0 @@ - - appnnpppnppa - ponoeioopoioieoiona - aeeinpoiaooa aoonoeoiop - peepepnpa opania - eeiieen onop inoip - nenepa oiaoeeoa inni - n ee nn ooae iann - iiie ennneo e oe - e pa piienoeinnananpii iino - ipano eenoopein npaaaaeoieo io - eanpoenaieaa aoaaaaoaaoeeapi - oeanei aeo pa - nnaanpep pneaooo - oaaoioipeiipineooaeno - oainnaaeaappooa - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_36.txt b/codex-rs/tui2/frames/openai/frame_36.txt deleted file mode 100644 index 9a23d2ddd6d..00000000000 --- a/codex-rs/tui2/frames/openai/frame_36.txt +++ /dev/null @@ -1,17 +0,0 @@ - - aapnppppnppa - anoonpenepnpnppooopa - pnonppopooaa aooiopipoip - aioopaaaa ooinoi - peannoinanpe aneo - e pea oa oiep onao - i n a n onn iaip - iini an aee ni i - iooi ee pooopppapppppa inii - aoin piaaeie aiaaaaaaaii neoa - o oin papea oooaaaaao ipoe - oponne peioe - neiipip pppoepa - opaooonaniapinopopnpo - aopeiappappppooo - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_4.txt b/codex-rs/tui2/frames/openai/frame_4.txt deleted file mode 100644 index 0c76cc5ce83..00000000000 --- a/codex-rs/tui2/frames/openai/frame_4.txt +++ /dev/null @@ -1,17 +0,0 @@ - - aennpnnpnppa - apopoie inneonioeopa - poniiipooa aainaoienna - eeipioepp oioonon - eieaininnaip nonion - eiiai oaiieon o nnip - onina ooonpoip p oni - ien pieoaii iii - ai ia nieoaeepipppaaaapp iio - ine i peeepaiannipeapeeanieeoi - ainonpnnpne oooooooooapiiia - pinoiaa paneooo - nnpea pa aeaponie - iiinaoonnnnnppnopnioa - onnieiaaaaanpoa - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_5.txt b/codex-rs/tui2/frames/openai/frame_5.txt deleted file mode 100644 index 2b06cade095..00000000000 --- a/codex-rs/tui2/frames/openai/frame_5.txt +++ /dev/null @@ -1,17 +0,0 @@ - - aennnpnnnppa - apopeoeiipinpnaiopp - oaenpiooa oanion pop - pieieienp paiiiia - pneoeaapianp a ninia - iiiie nainneia peii - iei p anainoip ooipip - ieiao panieai piai - ipiao eaiopionaipiaaappaoiei - aninppepoo io eennapnepeieoia - npiipipnnepa oooooooopinine - nnpenn a eaeopa - aeoninpa anppoono - aioipopnninappnoaino - apoaooaaapappoa - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_6.txt b/codex-rs/tui2/frames/openai/frame_6.txt deleted file mode 100644 index 2ca8bb0bc79..00000000000 --- a/codex-rs/tui2/frames/openai/frame_6.txt +++ /dev/null @@ -1,17 +0,0 @@ - - aennnpnnppaa - paneoinopippnapopa - pppiiioo aoienopoena - epioioanp npiiiip - oniiionnpon enninp - npeieaapninaip ennei - a pii aniean apiai - i iii ainieii ipnii - aeipi eoiionepaipiaappaenei - iinpeaaanp ienopiipnnnnipin - e i eiiineo ooooooooannei - p n epa peaeeia - eanaoop apaaeopp - aoiopop niaappoo eno - apnoppaaaaappo - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_7.txt b/codex-rs/tui2/frames/openai/frame_7.txt deleted file mode 100644 index f66ddaf5a65..00000000000 --- a/codex-rs/tui2/frames/openai/frame_7.txt +++ /dev/null @@ -1,17 +0,0 @@ - - aeinopnnppa - poapnipopnnnn aop - o piiioa aaia oeaop - nniiiinp ainoiep - aoioiiaienp e oiin - i niio aonnin ppiii - anoi aainini ooaii - aoeii npeioia o iii - eoio poaeineiinnaiinnonii - ipoiineiieoieniinnpnpnnini - n naeoioae ooooooooaeii - oa eaeaa popeneo - onppnnna paipoeoo - naonionniaaapoiope - oiooieiaapanoo - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_8.txt b/codex-rs/tui2/frames/openai/frame_8.txt deleted file mode 100644 index e54163d2c8a..00000000000 --- a/codex-rs/tui2/frames/openai/frame_8.txt +++ /dev/null @@ -1,17 +0,0 @@ - - aapanppnppa - pooeiiionn i op - ainpieeo poieionpap - aonoeippi o naiin - o eiepnien eoniip - iopinaaaniao npiii - ioiii oanniai iaieip - npiie ipneipoe eaeii - n niiaa eoniiepppppniii - noenaiopipiininaaiino - eenniniiieoaoaoonooeni - opoiniaa epeeeo - onaonnna aenaipeo - eeoipo nnponenpia - iiaooniappena - aa \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_9.txt b/codex-rs/tui2/frames/openai/frame_9.txt deleted file mode 100644 index a339de11184..00000000000 --- a/codex-rs/tui2/frames/openai/frame_9.txt +++ /dev/null @@ -1,17 +0,0 @@ - - enaoeppnp - eoaeiioon apna - e piieoaane naip - o iienna aapinaipp - eoonnooiop oeopepii - i iiioainnaa ioioiii - a ioniooinnpoepe aii - i ieapniiiaennai ii - opio eioiiiipiipiia - iaiiiapiaiaiiiiaaiii - o aaennieoooooiioeni - niiinpa pe aeia - npninn enanieo - aponaioepnnnoia - opa onninpeo - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_1.txt b/codex-rs/tui2/frames/shapes/frame_1.txt deleted file mode 100644 index 244e2470b4f..00000000000 --- a/codex-rs/tui2/frames/shapes/frame_1.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◆△◆△●□□●●□▲◆ - ◆●▲△□○□△□○●◇◇●◆ ◆■□◆ - ▲◇□◇□□□■○◆ ◆■□◆■◇●◇◇◇□ - ●□◆○□■▲▲◆ △□◇●◇▲ - ○○●△■○◇○◆○○ ■△◇○○▲ - ◇□ □◆ ◇□△●◇◇▲ ■△○◇◇▲ - □○■▲□ ■○◇□△■◇◆ ◇△◇ - ◇◇◆◇◆ ▲△△◇●◇□ ■◆◇ - ◇●◇■◆ ●◇◇○○◇■△◇□□□□□□◆□▲ ●■ ◇ - ◆◇●□ ◆●□◆ △□ ◇■◇◆◆◆△△▲▲▲◇△▲△▲◇ - ○○◆■○ ○○▲△△◆ ◆○□■■□ ○□■△▲●◆△ - □○▲ ■▲ ◆ ▲■△□◆◇ - ○○▲◆○□◆ ◆●◆□◇◆□■ - ○□▲○◆ □□△●●●●△●□□◆▲◇□ - ◆□■□◇◇◇◆◆◆▲◆●□□■ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_10.txt b/codex-rs/tui2/frames/shapes/frame_10.txt deleted file mode 100644 index f306dffc087..00000000000 --- a/codex-rs/tui2/frames/shapes/frame_10.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◆□□□□○□□◆ - □■◆□□□○◇△◆□▲ - ○◆▲◇◇△◇◇▲◇□○▲▲ - ◇◆◆△○◇●◆△○▲■■○○▲ - △ ●◇◇■◇○ ■▲△◆△◇△ - ◇◆ ■◇□◇△△○ ◆◆■◇ - ○ ◇□■□◇◇◇◇□ ◇△▲ - ■ ◇◇○□△□◇◇▲◆◆△○◇◇ - ■ ◇○ ○○◇●◇◇□○□●◇◇ - ◇ ▲◇○▲◇◆△◆□◆◆◆◇□◆ - ▲ ■◇◇◇◇◇■■ ○▲■○◇◆ - ○◆■▲○▲□■ ◆◇■■▲△△ - ◇■ ◇◇◇◇□▲△▲△◇△◆ - ●◆□□△◇□●◆ △△■ - □▲ ◆□○◆▲●□ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_11.txt b/codex-rs/tui2/frames/shapes/frame_11.txt deleted file mode 100644 index dcf944902b3..00000000000 --- a/codex-rs/tui2/frames/shapes/frame_11.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▲□□□□□□◆ - △■ ●◇◇◇○○▲ - △■◆◆◇◇●○□△○▲ - ◇◆◆◇◇●●▲◆●△△ - ◆■△●○◇□○■●◆◇◇▲ - ◇□◆◇◇□◆●●◇◆△◇◇ - □□ ▲◇◇◇◇◇◇◆◇●○ - ◇ ◇◇△◇◇○△ ◇■◇ - △ ■◇△◇◇□△□◇◆◇ - □□ ◆◇△●▲■△◇◆◇○ - ■◆▲ ◇◇◇◇●△◇○○◇ - ○◆▲△◇◆□△□□●◇◆ - ◆ □◇○○○◆◇●■ - ○□ □◇◇▲◆△□◆ - ○◆ ■□□◆□◆ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_12.txt b/codex-rs/tui2/frames/shapes/frame_12.txt deleted file mode 100644 index d8d1fbf334f..00000000000 --- a/codex-rs/tui2/frames/shapes/frame_12.txt +++ /dev/null @@ -1,17 +0,0 @@ - - □□□□□△ - ▲●◆◆△◇▲○ - ■ ◇△○□▲ - △□◇◇◇●●■◇ - ◇ ■◇◇◇◇△△◇ - ◇■△△□○■◆◇■ - ◇ ◇△○◇◇○ - ◇ △□○◇◇■ - □▲ ◇△◇◇□◇◇ - ◇ △◇□●□◆ - ◇△ ◇■●□□△ - ▲ □◇ ◆▲◇ - △ □□◇▲□○◇ - ■○△■▲◇○◇◆ - ○ ■◇○△△ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_13.txt b/codex-rs/tui2/frames/shapes/frame_13.txt deleted file mode 100644 index 1387fc9b912..00000000000 --- a/codex-rs/tui2/frames/shapes/frame_13.txt +++ /dev/null @@ -1,17 +0,0 @@ - - △□□□▲ - ◇◆◆◇◇ - ◇◆◆◇■ - ◇□□◇◆ - □ ◇◇ - ■△▲◇◇ - ▲ ◇◇ - □ ■◇ - △□ ◆◇△ - ◇■ □●◇ - ■◆△△ ◇◆ - ◇◆◆◆◇ - ◇□▲◇◆ - □◆◆◇● - ▲ △■● - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_14.txt b/codex-rs/tui2/frames/shapes/frame_14.txt deleted file mode 100644 index 70a5070ba9b..00000000000 --- a/codex-rs/tui2/frames/shapes/frame_14.txt +++ /dev/null @@ -1,17 +0,0 @@ - - □□□□● - ▲△◆◆△○ - ◇◆◇◇△ ◇ - ◇◇△◇■○◇▲ - ◇◇◇○ ◇ - ◇□◇◇ ■◇ - ◇◇◇◇ △ □ - ◇◇○◇◆ ○ - ◇◇◇◇ ■ - ■○△△ ●△ - ○◇■■▲△▲◇ - ◇□◇◇◆◆◆◇ - △ ■◇●●●◆ - ◇○△□◆◆△ - ◆□ ■●△ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_15.txt b/codex-rs/tui2/frames/shapes/frame_15.txt deleted file mode 100644 index 584e0e043a9..00000000000 --- a/codex-rs/tui2/frames/shapes/frame_15.txt +++ /dev/null @@ -1,17 +0,0 @@ - - □□□□□◇◆ - ◆▲●◇◇○△□▲ - △△◇□●○◇■□◇ - ◇△◆ △△▲△◆◆◇ - ◇◇●■●○◇◇△■○◇ - ○◇◇▲◇◇△△◇ ■ - ◇◇ ◇◇◇◇◇◇▲◇ - ◇◇△◇◇□◇◇◇ ■▲◇ - ◇◇□▲▲□◇◇◇◆△△◇ - ◇◆◇●△◇○◇□△ △◇ - ◇△◇◇◇◇◆◇ ◆ ● - ◇□□△○◇◇○▲ ● - ■◇○▲ △△△◆ ●◆ - ◇○◇◇○○△□○△ - ○○□○◆◆◆△ - ◆◆ \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_16.txt b/codex-rs/tui2/frames/shapes/frame_16.txt deleted file mode 100644 index af6c8368553..00000000000 --- a/codex-rs/tui2/frames/shapes/frame_16.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◆●□■□□□◇◆ - △○◇◇□◇◇○◇●○ - ◇△△■○△●○◇◇ ■○ - △△△ ●□◆○◇○◇◇□□◇ - ◇◇●◆△□■●◇◇□△◇◆○ - ◇◇◇□◇■ ◇○● ◇ ◇△◇ - ○○◇ △◆▲◇◇◇◇△□■▲ ◇ - ◇△● ◇◆◇◇△◇◇◇■● ◆◇ - ○○◇◇◇□◇△○◇◇■□□ △ - □■◆◆▲●●○□◇△◆◇ ▲◆◇ - ◇◇◇□■■△□○◇●■●△◇◇◆ - ◇◇□ ◇◆ ◆△△▲ △ - ○◇□ ◇ △▲△◆▲◇ - ○◇◇■◆□◇△△□◆◇ - ■△△◆●△◆◆●□ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_17.txt b/codex-rs/tui2/frames/shapes/frame_17.txt deleted file mode 100644 index 4a158cf6094..00000000000 --- a/codex-rs/tui2/frames/shapes/frame_17.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▲●□□□●●◇▲◆ - ◆●□□◇◇◇□●□◇▲■○▲ - ▲□○△◆●●◆□▲■◇◇◇◆■○ - △◇△◆△◇■◆◆ ■◇●○◇◇◇●○ - ◇△◆ ◇ ◇■△○●○△△ ▲ - ◆●□▲△◆ ▲△△◇ △▲●△◇△△ - □ ●△◇ ▲△△◇◆ □■◇◇△●◇ - ▲■●◇ △△◇◇△◇▲◇◇●●□ - ▲△□△○●●□◇○◆◇○○△○◇◇◇ ◇ - ◇■◆●◆◆◇△□○△◇◇○ ○□■□○ - ○○○△■■■□□□□○◆◇□△△○ ○■ - ○◇◇◆□ ◆△◇◇◆ ● - ◆○○ □○◆ ▲■▲△○◆△ - ▲ ◇□□●○□◆◆●□△◇■ - ◆□●△◆▲□◇□◆□□ - ◆ \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_18.txt b/codex-rs/tui2/frames/shapes/frame_18.txt deleted file mode 100644 index 16bf8c1b581..00000000000 --- a/codex-rs/tui2/frames/shapes/frame_18.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◆●●□●●□●◇▲◆ - ◆□■◆●▲□□◇◆◆▲■□●●◆ - ◆△△◇□■□●■▲■△◆■□△△○ □ - ▲◇◇□△▲○■□ ■○◆◆△●◇○●○○○▲ - ◆○△△ △◇ ◆■□◆○△◇△◇◆○●○○◆ - △○◇ ▲▲◇△◆◆◆△▲□▲◇■▲△○△◆△◇ - ◇◇○△△ □△□△◇■◆△■□◇●◇●▲ - ■○▲◇◇ ○○■◇◇●○ ◇●◇■ - ○■■ ▲○●●●●●□◇◇▲◇□△○▲ ◇□◇◆ - ◆○○◇□○□ ◇◇△◆◆○◆◇◇◇◆●◇◆○ ▲ - ◇△◇○◆■■□□□□□◆ ○◆◆△◇△□△◆▲◆ - ○○ ▲○▲ ◆◆□■△□◆■ - ○○□□○○△ ◆□○◆△○▲◆ - ■◇△□□□□●●●○■□◆△◆▲□ - ■◇◇◆△◆◆▲△□■●□■ - ◆ \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_19.txt b/codex-rs/tui2/frames/shapes/frame_19.txt deleted file mode 100644 index e1bc51ae1be..00000000000 --- a/codex-rs/tui2/frames/shapes/frame_19.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◆◆●●□◇□●●◇△◆ - ◆□◇◇◇○△●●●◆□◆▲◇□▲▲ - ▲□●●△■◇□□◆ ◆□△▲◆□●○ - △●◇□▲○◆ ◆●●□▲○◇□▲ - ◇◇△◆△◆ ▲◇○■△■◇○ ◇○▲ - ◇○△□■ ◆◇□◇△ △△ ■△□◇ - ○●◇◇■ △□○△◇□▲◆ △△△□◇ - ◇◇◇◇ ◇●■○◇■▲ ◇ ◇◇◇ - ◇□◇○▲●●●●●●◇◇◇◇□□●□◇● ○ ■◇◇ - ○●△▲◇●□△○□□○▲◆▲ ○◆ △○◆○▲△△◇◆ - ■○■△□◇■■■■■□□■ ○○◆△◇△△ △△■ - ○○○□◆○ ◆△△◆△△■ - ■ ◆○◇◇□▲ ▲●●△△◇●◆ - ■■△◇□△◇□◆◆●●△□■ ●□◇■ - ■◇△◆△▲■◆▲△○□▲○ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_2.txt b/codex-rs/tui2/frames/shapes/frame_2.txt deleted file mode 100644 index af71459f5e9..00000000000 --- a/codex-rs/tui2/frames/shapes/frame_2.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◆△◆△●□●●●□▲◆ - ◆□▲△□●□◇◇◇●◇◇●◆■◇□□◆ - ▲◇□◇○□□◇□◆◆ ◆■□●□△◇◇●◇▲ - ◆◇△◆△□■▲▲◆ ▲▲○◇△◇▲ - ▲○■△□○◇○◆○○◆ ○▲◇○◇▲ - ■△□■◆ ◇□○□△○□ ○○◇□◇ - ▲◇■□◇ ○△◇●◇◆◇▲ △■◇■◇ - ◇■ ○◆ ▲◇□◇◇●□ ◇■△△◇ - □■■●◇ ●◇○○◆◇■△◇□□□□□□□□▲◇◆◇◇□ - ◆●■◇ △◇◇■ △□ ◇●○◆◆◆◆△△◆◆◇□△ ◇ - ○○○ □ □○△△◇■ ◆■□□□□□□□■◇▲□◇◇ - ■○◆△△▲ ◆ ▲□●△▲□ - △○◆◆◇□▲ ▲●◆□ ◆◇■ - ◆◇○●◆◆■□□△●●●●△●□◆◆◇◇□ - ◆□□□□◇◇◆◆◆◆◇●△□■ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_20.txt b/codex-rs/tui2/frames/shapes/frame_20.txt deleted file mode 100644 index c5eb01382d6..00000000000 --- a/codex-rs/tui2/frames/shapes/frame_20.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◆◆□●◇◇●□●▲◆◆ - ▲●●■●▲◆▲●●●◇◇◆○□○□▲ - ▲▲■◆□□■◇□■ ◆■○■○□□◇□◆ - ◆◇■◆□◇□ ◆▲□◇○△□○○ - ◆◇ ▲◇□ ◆△ ◆◇◇○□○◇◇○ - △◆▲◇■ □□■●◇■△ ■○◇◇○ - ●◇●□◇ ▲□ △□□○ ▲○◇△ - ◇◆○△◆ ■■●△◇■▲ ◇◇■◇ - ◇△◇◇●▲●●●●●●●●◆▲■●○○○○□ △ ◇○ - ■◇○◇◆■ △◆◆◆◆◆◆◇○ □○○■◇●○ ▲△ ◇◇ - ○▲○○■■■■■■■■□■ ○▲●◇◇▲◇ △○ - □ ○□△▲ ◆△□△◇△ - ○◆○□▲◇◆ ▲●□◆●□■ - ○▲▲△■◇□○●●▲△■◇■◆▲△□■ - ◆□□◇◆ ●◆◆◆◆◇□■■ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_21.txt b/codex-rs/tui2/frames/shapes/frame_21.txt deleted file mode 100644 index 944b99f0581..00000000000 --- a/codex-rs/tui2/frames/shapes/frame_21.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◆◆▲●□●●□●□▲◆◆ - ▲□□◆◆◆◆□◇●◇□●◆◆▲□□◆ - ●□○◆□□○■◆ ◆○◇□□◆◆□▲ - ▲□◇●■■ ◆▲●□▲●□▲ - △▲△□◆ ▲◇△◆○ ○○◆○▲ - △■△◇ ▲□□◆△△ ○○○▲ - ◇■◇◆ ◆ △◆▲△■ ○●○■ - ◆○ ○● ■◇▲ ◇ ◇ - ◇ ◇ ▲□●●●●●□□□ ○◇▲■◇▲ ●△ ◇ - ◇ ◇○ ◇◆ ◆▲◇ ○■◆□◇▲ △■△△ - ● ■△ ◆◆■■■ ■◆ ■□◆▲● △◆ ▲ - ■□ △●◆ ◆□■ △ - □◆ ○▲◆ ◆△●□■ ▲◆ - □●◆ □■□□△◇◇●△●□■■ ▲◆ - □□□●□◆◆◆◆▲◆●□□◆ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_22.txt b/codex-rs/tui2/frames/shapes/frame_22.txt deleted file mode 100644 index 60ea930d46d..00000000000 --- a/codex-rs/tui2/frames/shapes/frame_22.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◆▲□●□●●□●□▲◆ - ◆□△□◇△◆△◇△○◇◇●◇□△□□◆ - ▲●□●◇○□□◆ ◆■□○□□□◆◆□▲ - ●□◇□□■ ▲▲◇◇◇◆□○ - ○◇◇△■ ▲◆△◇●◇△△●■◇ - ○◇□△◆ ▲◇△◇●○△◆ ○◇○●◇ - ◇□□○△ ◇□◇○□△■ ◇◇△◇ - ◇◇●◇◆ ◇○◇◇○○ ◇○◇■◇ - ◇◇▲■▲ □◇●●●●●●□□◆○□◇◆ ○▲ ◆◇●■ - ○◇● □◆○○▲ △▲■◇△△◇ ○□●◆■◇▲▲ △○● - ○○○◆◇ ■■■■■□□■◆ ◆△□□◆△△△■▲◆ - ○□○◆○▲ ◆□▲■◇▲■ - ■■□● □□◆ ◆△□▲■□◆△ - ■△□□▲■□◇●●□●●△●□■■■▲□ - ■◇■◇◆●◆◆◆▲●◆△●□◆ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_23.txt b/codex-rs/tui2/frames/shapes/frame_23.txt deleted file mode 100644 index 5d340640bf3..00000000000 --- a/codex-rs/tui2/frames/shapes/frame_23.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◆▲□◆●□□□□●□◆◆ - ◆●◇□◇○◆▲◆●●▲◇◆◇□◇□◆ - ▲■□◇■▲●□◆ ■◇△□◇○●◇◇▲ - △●◇□●△◆ ◆●●○◇◇●○ - △△◇◆△■ ▲△▲▲■□△□○◇○ - ▲○◇■△◆ ◆□□□■■△□■◇◇○◇▲ - ◆◇◇■◇ △△△△ ◆◇◆ ■ ◇■□ - ◇△◇ ◇ ◇■◇▲◆○▲ ◇ □△◇ - ○■○ ◇□◇●●●●●●●●◇◆□◇◆■○◆ ◇ ◇◇◇ - ◇○○◆◇■●■ △◆○ ○◇◆○▲◇ ○◆◇ - △■◇ ◇ ◆■■■■■■■ ■◆△●●◇◆△○△◆ - ▲◇◇◆◇△ ▲△◇○○△◆ - ○ ◇▲■△◆◆ ◆△■●□△□△ - ○●○□●□□○◇●●◆□□■▲◆□□◆ - ■◇ □●○◇◆◆□◆◆□□◆ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_24.txt b/codex-rs/tui2/frames/shapes/frame_24.txt deleted file mode 100644 index 558224147dc..00000000000 --- a/codex-rs/tui2/frames/shapes/frame_24.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◆◇◆●□□□●●▲◆ - ▲■◆△■◇◆◆◇●●□▲◆■□ - ▲■■●○△□■◇ △◆△□◇□□▲ ○ - ▲■△△▲△◇●▲○◆◇△◆ ◆□○◇○◆■ - ▲ △△○△▲ ◆■■△○◆◇◆◆○◇▲■ - ◇▲○□○◆ □▲▲△ △◇● △■○ - ◇ ◇◇ ▲△◆▲△■△○○◇○○◇○ - ◇ ○◇◇ ◇▲●△○◆◇◇◆△△◆◇◇ - ◇△ ◇●●●△□□◇◇◇□□◆□▲◆◇ ◇ ◇◇ - ◆ △ □◇△▲▲●●▲▲◇△○○□□◇ ◇□◇ - ○ ■○◇○□■■■■■■◆ ○▲△□○◇●△▲◆ - ○ ○▲□○ ◇ ■△●△ - ○△□○◆◇△ ◆●■◆△△◆□ - △▲□▲○○■◇◇△●●●◇○◆△◆ - □△◆□□●○◆◆●●□□ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_25.txt b/codex-rs/tui2/frames/shapes/frame_25.txt deleted file mode 100644 index 38d32507640..00000000000 --- a/codex-rs/tui2/frames/shapes/frame_25.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◆□●□●□□□□◆ - ▲ □▲□◆◆●○◇□◇◇▲ - △◇△△▲▲■△◇□○●○◇◇◇◆ - ◇□□◇ △ ●◇ ■○◇▲□○△ - ▲◆◆○▲△■○●△◆◇■ ◇△△○△□ - ○▲◇◇◇◆□□△○◇△ △◇▲◇△△◇▲ - ◇△◇□◇ ◇◆◆△○◆△◇○△◆■◇●◇ - ◇◇◇△◇△■ ◇◆●○△◇○◆◇●◇◇○ - ○□○◇■◇◆◆●●●●□◇◇○○◇□□◇○ - △▲■◇△◇▲◇◆◇◇●□◆◇○□●△○◇ - ○◇□△○◇◆■■■■■■□△◇○◇△◇◆ - ◇◆■◇●□●○▲▲□△◆△▲△△◇△ - ○◆○◇◆○◆□●△▲○△◆◇◇△ - ○△●○○◆□●◇□□ □◇□ - ◇▲■□○□▲◆□◇□ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_26.txt b/codex-rs/tui2/frames/shapes/frame_26.txt deleted file mode 100644 index 4aac44389a9..00000000000 --- a/codex-rs/tui2/frames/shapes/frame_26.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◆●□□□■□▲ - △□◇●△○◇□●◇▲ - △□■■◇●○◇○■■○▲ - ●□■▲◇◇◇◆◇◇◆□△○ - ◇◆▲○◇△◇■□○△●◇○● - ○ ■◇△△△■□◆○■○○△ - ◇ □■●◇◇△◇◇●●■□ - △▲ ○◇◇◇○○●◇◇△◇◇ - ◇ △○□●□◇□●●△○◇◇ - △◆◇△▲◇◆◆▲□○○□◇ - ◇◆■△◇□■○▲△◆□○◇◇ - ○◆◇◇△○ △△■◇◇◆ - □ □◇○ ○◆△◇△▲ - ◇■□○○■□◆□□● - ○△△○□◇◆○□ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_27.txt b/codex-rs/tui2/frames/shapes/frame_27.txt deleted file mode 100644 index 9896590f797..00000000000 --- a/codex-rs/tui2/frames/shapes/frame_27.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ●□□□□▲ - ◇●△△◇△○ - ●■◆ □◇△■◇ - ◇◇◇○◇◇●◇◇ - ◇ ◆◇◆▲■● - ◇◆△◆■◆◇◇◇◆ - ● ■◇◇◆□◇◇ - ◇ ◆◇◇◇●■◇◇ - ◇△ △ ◇□◇◇◇◇ - ■● △■◆◇◆□◇◇ - ◇▲ ◆○□□□△■ - ◇◇□□◇◇◆△◇ - ◇●●□●▲△◇○ - ○◆◆◇○●△△ - ■○■ ○○△ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_28.txt b/codex-rs/tui2/frames/shapes/frame_28.txt deleted file mode 100644 index 16b349dc3d5..00000000000 --- a/codex-rs/tui2/frames/shapes/frame_28.txt +++ /dev/null @@ -1,17 +0,0 @@ - - △□□● - ◇□◆◇◆ - ● ●◇◆ - ◇▲◆□◇ - ◇ ◇◇ - ◇ ▲■◇ - ○◇◇ ◆◇ - ◇▲■■◇ - △○ ◆◇ - ◇◇△△◇◇ - ◇ ◆◇ - ◇●□●◇ - ◇●□◇◇ - □◆◆△◇ - ◇ △□◇ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_29.txt b/codex-rs/tui2/frames/shapes/frame_29.txt deleted file mode 100644 index 24be1563b27..00000000000 --- a/codex-rs/tui2/frames/shapes/frame_29.txt +++ /dev/null @@ -1,17 +0,0 @@ - - □□□□□▲ - ▲◇□△ □○ - ○▲◇◇ ■■◇ - ◇◇◇◇○○▲△ - ○ ◇■◇ ◇ - ◇▲◇◇◇ ◆◆ - ◇○○◇◇◇ ○ - ◇◇●◇◇ - ◇◇◇□◇ ◆ - ◆◇●◇◇ ◆ - □◇◇△◇○ ● - ◇△◇◇◇ ◆◇ - ◇◇◇◇◆◇◆◇ - ◇●◇□△■◇■ - ◇◇□ △▲ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_3.txt b/codex-rs/tui2/frames/shapes/frame_3.txt deleted file mode 100644 index 3f55b79ac59..00000000000 --- a/codex-rs/tui2/frames/shapes/frame_3.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◆△●●□□●●●●▲◆ - ◆□▲△○◇◇□◇■●●◇●◆○◇□□◆ - ▲●△□●◇□■■◆ ◆■◇ □□□△○◇▲ - △□△○◇●▲□◆ ◆●□ ◇◇○ - ■△□◇△○△◇◇□○◆ ○○○◇◇◆ - △◇○○△ △▲◇○●○○ ◇ ○○◇ - ●□ ◇ ■□○◇◆■◇▲ △□△◇▲ - ▲▲◇▲◇ ▲▲◇■△○◇ ◇◆◇●◇ - ▲■■◇ ●◇□□◇◇□▲◇●□□◆◆◆□□◆◇○■●◇ - ▲○△○▲ △△□△●●◇ ◇□◇◆◆▲ ●△▲●○◆◇○◇◆ - ■○◇ ◇ ■◇◇□▲■ ■□□□■■■□□▲△△△▲△ - ■□◇ ○□ ◆ △○□○△△ - ●○△△□○▲ ▲□●△■▲◇■ - ●○□▲◆□□○●●●●▲△●□●◆◆□■ - ◆□□□◆△◇◆◆◆◆◇●△○■ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_30.txt b/codex-rs/tui2/frames/shapes/frame_30.txt deleted file mode 100644 index 54886a319d0..00000000000 --- a/codex-rs/tui2/frames/shapes/frame_30.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◆●■□□□▲ - ◆△●◇◇○□■●○ - ■■○◇▲○○◇□▲○ - ◇◇○◇△◇◆○◇○ ○▲ - ●□◇◇◆○◇▲◇▲ ◇ - ◇□◇○□ □◇◇◇◇△◇ - ○△◇◇◇○◆ ○△○◇ ●◆ - ■◇ ■□●◇▲◇■◇▲ ◆ - ■◇◆■○◇●◇○△◇▲ ◆ - ■◇◇○◇◆◆◇◇ □▲ ◆ - ○◇◇●○◆□◇◇◇□◇◇ - ◇□△△▲△□○◇◇■ ◇ - ○◇○◇◇△□◇◇▲◇△ - ▲○▲□●○△◇▲△■ - ○▲△◇○◆◆●■ - ◆◆◆◆ \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_31.txt b/codex-rs/tui2/frames/shapes/frame_31.txt deleted file mode 100644 index b3989b89df9..00000000000 --- a/codex-rs/tui2/frames/shapes/frame_31.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◆△△□□□□●▲ - ◆□□●□▲◆■□○■□▲ - ▲◇○△△▲●◆◆△■◇◆□○ - ▲◇△□◆◆□●◆▲○○◇○◇◇○ - ○◇◇◇◇□▲□○◆▲■○◇△◆◆ - ●◇○◆◇◆◇■■○●□▲△△◇▲■◇ - △◇◇ ◇○□◇○△●△●□◇□◇△◇ - ○○▲ ◇◇■◇○ ◇○■■◇◇○△◇ - ◇○▲◆◇■○◇◇◇△□◇◇◇◆ ◇ - ■◇◇◇△△◇◇◆◆◆○◆◇○◇◇ □ - ○□◆△□◇▲ △□□□◆△◇■◇◆ - ◇◇◇○○○▲ ◆●□ ◇ - ●◇□●◆ ▲ △ ◇□◆△◆ - △◇○◇△◆◇◆▲◇■▲◇◆ - ■■●◇◆□□△△●□ - ◆◆◆◆ \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_32.txt b/codex-rs/tui2/frames/shapes/frame_32.txt deleted file mode 100644 index 919eee3b0fd..00000000000 --- a/codex-rs/tui2/frames/shapes/frame_32.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▲□□□□●●◇▲◆ - ●□◇□□◇○◇◇□□◇◇▲ - △○□●○▲□○△▲◆□◇□○■□▲ - ■△△◇◆▲◆●▲ ▲ ▲◆○◇□▲ ▲ - △△◇▲◇□○■□◇● ■◆▲◇○△○□○▲ - ○△○○▲○□◇◆△◇◇□◆ ◇□△ □ - □◇■●▲◆■○◇◇▲▲◇▲●◆△◆△△◇■◆ - ◇◇◇▲◆ ■◆◇◇■△■◇●▲◆■○◇■■◆ - ○◇◇▲◆ ■◆◇■◆◆◇□□□◇◇◇●◇■●◆ - ■■■◆◆◇□○■○◆◆◆◆◆◇◇□◇●◇▲◆ - ▲○□◇◇△ ◇□■■□□□○●△△△△△ - ○▲◇○○○ ▲○◇△■◇ - ■□□◇◇◇□◆ ◆△◆◇△ ▲ - ○▲○■◇□◇●●□◆◆□■◆△ - ◇●●◆◆▲□△□◆■■ - ◆◆◆ \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_33.txt b/codex-rs/tui2/frames/shapes/frame_33.txt deleted file mode 100644 index c5598aa7a73..00000000000 --- a/codex-rs/tui2/frames/shapes/frame_33.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◆▲□●□●□●◇△◆ - ◆□□◇◇○□●◇△◇▲◇□◇□△ - ◆△○◇□□□▲●■◆ ■□△◇◇□■▲ - ▲○△◇△◆◇▲◆ □◇◇○◆□ - ◆△◇△◆◇◇△○○○ ◇□○○○ - △△▲ ○◆○○◆○▲ △○△◆○ - ◇◇□ ○▲□◇◆◇○ ◇□◇◇ - □◇▲▲ ▲■■○△●▲ ○■◇□ - ◇◇◇ ◇ ▲◆ △□▲▲▲●●●□◇◇□△◆◇○◇ - ○△◇■■△△▲△◆◇◇◆◆◆◆◆◇◇□○○○◇◇△ - ○●○▲ ◇△◇△ △ ■■■■□□□ ◇◇▲△○◆ - ○□○□▲■▲ △△●△●△ - □○■□▲□●▲ ◆□◇▲△□○■ - ○◇■□○□◇▲●□◆■●▲○□◇□ - ■●●□▲◆◆◆○□□●▲■ - ◆◆◆◆ \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_34.txt b/codex-rs/tui2/frames/shapes/frame_34.txt deleted file mode 100644 index 5a44de82561..00000000000 --- a/codex-rs/tui2/frames/shapes/frame_34.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◆▲●□□□□●◇▲◆ - ◆□□□△○●◇■◇□□●□□●▲●◆ - ◆△△▲◇□■△◇□◆ ◆○○○▲■□□▲ - ▲△◆◇□◆△▲◆ □◇▲□○○ - △△◇○▲△◇ ◇■ ▲ ○○□○○ - ▲△○◇◆△ ○○■◇○□○ ○▲○◇○ - ◇□○◇◆ ■○◆□◇◆○ ◇●◇◆ - ○◇●■△ ◇□◆◇△■◇ △ ◆○ - ▲■△△■ △○△△■◆●●□□□□□□◇◆◆■ ◇○◆ - ◇◇○▲△▲▲○■△△■◇◇◇◆◇△◆◆◆▲△◆◇◇◆◇○ - △◆○▲ ◇○◇□○ ■□□◆◆◆◆○ ▲△▲○◇■ - △▲○●●○◆ △●△◇△■ - ○□◆◇□○●▲ ◆□●■◆△▲◆ - ▲○△◇■◇□▲●▲□●□●□◆□□△■ - ■◇○●●◆◆◆◆◆●●□●○ - ◆◆◆ \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_35.txt b/codex-rs/tui2/frames/shapes/frame_35.txt deleted file mode 100644 index 1c1728676b2..00000000000 --- a/codex-rs/tui2/frames/shapes/frame_35.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◆▲□●●□□□●□▲◆ - ▲□●□△◇□□□■◇□◇△■◇□●◆ - ◆△△◇●□□◇○□■◆ ◆■□●□△■◇□▲ - ▲△△□△▲●▲◆ □□◆○◇◆ - △△◇◇△△○ ■○□▲ ◇○□◇▲ - ●△●△□◆ ■◇◆■△△□◆ ◇○○◇ - ○ △△ ○○ ■□◆△ ◇○○○ - ◇◇◇△ △○○●△■ △ ■△ - △ ▲◆ ▲◇◇△●■△◇●●◆●◆●□◇◇ ◇◇●□ - ◇▲○●■ △△●■□□△◇● ○▲◆◆◆◆△■◇△■ ◇■ - △◆○▲■△○◆◇△○◆ ◆■○○○○□○○□△△◆▲◇ - ■△○○△◇ ◆△□ ▲○ - ○●◆○●▲△▲ ▲●△◆□□■ - ■○◆■◇□◇▲△◇◇□◇●△□■◆△●■ - ■○◇●●◆◆△◆◆□□□■○ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_36.txt b/codex-rs/tui2/frames/shapes/frame_36.txt deleted file mode 100644 index 0cac995ed7a..00000000000 --- a/codex-rs/tui2/frames/shapes/frame_36.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◆◆□●□□□□●□▲◆ - ◆●■■○□△●△□○□●□□■□□□◆ - ▲○■○▲□□▲□■◆◆ ◆■□◇□□◇▲□◇▲ - ◆◇■□□◆◆◆◆ ■□◇○■◇ - ▲△◆●○■◇○◆○▲△ ○○△□ - △ ▲△◆ □◆ ■◇△▲ ■○○□ - ◇ ● ◆ ○ ■○○ ◇○◇▲ - ◇◇○◇ ◆○ ◆△△ ○◇ ◇ - ◇■■◇ △△ ▲□■■▲□□◆□□□□□◆ ◇○◇◇ - ◆□◇○ ▲◇◆◆△◇△ ◆◇◆◆◆◆◆◆◆◇◇ ○△■◆ - ■ □◇○ □◆□△○ ■□□○○○○○■ ◇▲■△ - ■▲■○●△ ▲△◇■△ - ○△◇◇□◇▲ ▲□□■△▲◆ - □▲◆■□□●○●◇◆□◇●■▲■▲●□■ - ◆□□△◇◆▲▲◆▲▲□□□□■ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_4.txt b/codex-rs/tui2/frames/shapes/frame_4.txt deleted file mode 100644 index 31e55f9cb8c..00000000000 --- a/codex-rs/tui2/frames/shapes/frame_4.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◆△●●□●●□●□▲◆ - ◆▲■□□◇△ ◇○●△□●◇■△□▲◆ - ▲□●◇◇◇□□■◆ ◆○◇○○□◇△○○◆ - △△◇▲◇■△□▲ ■◇□□○□○ - △◇△◆◇●◇○●○◇▲ ○■○◇□○ - △◇◇○◇ ■◆◇◇△□○ ■ ○●◇▲ - ■○◇●◆ ■□□○□■◇▲ ▲ ■●◇ - ◇△○ ▲◇△□○◇◇ ◇◇◇ - ◆◇ ◇◆ ●◇△■○△△□◇□□□◆◆◆◆▲▲ ◇◇□ - ◇○△ ◇ ▲△△△▲◆◇◆●○◇▲△◆▲△△○●◇△△■◇ - ◆◇●■○▲○○▲○△ ■□□□□□□□■○▲◇◇◇◆ - ▲◇○■◇◆◆ ▲○●△□□■ - ○○▲△○ ▲◆ ◆△○▲□●◇△ - ◇◇◇●◆□□○●●●●▲□●□□○◇□◆ - □○○◇△◇◆◆◆◆◆●▲□◆ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_5.txt b/codex-rs/tui2/frames/shapes/frame_5.txt deleted file mode 100644 index a8ae0ab8193..00000000000 --- a/codex-rs/tui2/frames/shapes/frame_5.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◆△●●●□●●●□▲◆ - ◆□■□△□△◇◇□◇●□●◆◇■□▲ - □◆△○□◇□■◆ ■○●◇□○ ▲□▲ - ▲◇△◇△◇△●▲ ▲○◇◇◇◇◆ - ▲○△□△○◆□◇◆○▲ ◆ ○◇○◇◆ - ◇◇◇◇△ ○◆◇○●△◇◆ □△◇◇ - ◇△◇ □ ◆○◆◇○■◇▲ ■■◇□◇▲ - ◇△◇◆■ ▲◆●◇△◆◇ □◇◆◇ - ◇▲◇○□ △◆◇■▲◇□●◆◇□◇◆◆◆□▲◆■◇△◇ - ◆○◇○▲▲△□□■ ◇■ △△○●○▲●△▲△◇△□◇◆ - ○▲◇◇▲◇▲○○△□◆ ■□□■■■■■▲◇●◇○△ - ○○□△○○ ◆ △◆△□▲○ - ○△□○◇●□◆ ◆●□▲□■●□ - ○◇□◇▲□□○●◇●◆▲□●□○◇●□ - ○▲□◆□■◆◆◆▲◆▲□□◆ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_6.txt b/codex-rs/tui2/frames/shapes/frame_6.txt deleted file mode 100644 index e0b1f854547..00000000000 --- a/codex-rs/tui2/frames/shapes/frame_6.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◆△●●●□●●□□◆◆ - ▲○●△□◇●□□◇□□●◆▲□□◆ - ▲▲▲◇◇◇□■ ◆■◇△●□□□△○◆ - △▲◇□◇□◆●▲ ○□◇◇◇◇▲ - ■●◇◇◇□○○□□○ △○○◇○▲ - ●▲△◇△◆○□●◇○◆◇▲ △●○△◇ - ◆ □◇◇ ○○◇△○○ ○□◇◆◇ - ◇ ◇◇◇ ◆◇●◇△◇◇ ◇□○◇◇ - ◆△◇▲◇ △■◇◇□●△□◆◇□◇◆◆□□◆△○△◇ - ◇◇○▲△◆○◆●□ ◇△●□▲◇◇▲●●●○◇▲◇○ - △ ◇ △◇◇◇●△■ ■□■■■■□■◆○○△◇ - ▲ ● △▲◆ ▲△◆△△◇◆ - △◆○◆□□▲ ◆□○◆△□▲□ - ◆■◇□▲■□ ●◇◆◆▲□□□ △●■ - ○▲●□□▲◆◆◆◆◆▲□□ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_7.txt b/codex-rs/tui2/frames/shapes/frame_7.txt deleted file mode 100644 index 7e69d68d573..00000000000 --- a/codex-rs/tui2/frames/shapes/frame_7.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◆△◇●□□●●□▲◆ - ▲□◆□○◇□□□●●○● ◆□▲ - □ ▲◇◇◇□◆ ◆○◇◆ □△◆□▲ - ○○◇◇◇◇●□ ○◇○□◇△▲ - ◆■◇□◇◇○◇△○▲ △ □◇◇○ - ◇ ●◇◇■ ◆■○●◇○ ▲▲◇◇◇ - ◆○■◇ ○◆◇○◇○◇ □□○◇◇ - ◆■△◇◇ ●□△◇□◇◆ ■ ◇◇◇ - △■◇□ ▲■◆△◇○△◇◇●●◆◇◇●●□○◇◇ - ◇▲■◇◇●△◇◇△■◇△○◇◇○○▲●▲●○◇○◇ - ○ ●○△□◇□◆△ □□□□□□■■◆△◇◇ - ■◆ △◆△◆◆ ▲■▲△●△■ - ■●▲□○○○◆ ▲○◇▲□△□■ - ○◆■○◇□○●◇◆◆◆□□◇□□△ - ■◇□■◇△◇◆◆▲◆●□□ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_8.txt b/codex-rs/tui2/frames/shapes/frame_8.txt deleted file mode 100644 index b7bddd4156a..00000000000 --- a/codex-rs/tui2/frames/shapes/frame_8.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◆◆□◆●□□●□▲◆ - ▲■□△◇◇◇□●● ◇ ■□ - ◆◇●▲◇△△□ ▲■◇△◇□○□○▲ - ◆□○□△◇▲□◇ ■ ●○◇◇○ - ■ △◇△▲○◇△○ △■○◇◇▲ - ◇■▲◇○○○◆○◇◆□ ○□◇◇◇ - ◇□◇◇◇ ■◆●○◇◆◇ ◇○◇△◇▲ - ○▲◇◇△ ◇□○△◇▲□△ △◆△◇◇ - ○ ○◇◇◆○ △□●◇◇△□▲□▲▲○◇◇◇ - ○□△●◆◇■▲◇▲◇◇●◇●◆◆◇◇●■ - △△○○◇○◇◇◇△■○□○□■○□■△○◇ - ■▲■◇○◇◆◆ △▲△△△■ - ■○◆□○○○◆ ◆△○◆◇□△□ - △△■◇▲□ ●●□□●△●▲◇◆ - ◇◇◆□□○◇◆▲□△●◆ - ◆◆ \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_9.txt b/codex-rs/tui2/frames/shapes/frame_9.txt deleted file mode 100644 index 4342d3c81e5..00000000000 --- a/codex-rs/tui2/frames/shapes/frame_9.txt +++ /dev/null @@ -1,17 +0,0 @@ - - △●○□△□□●▲ - △□◆△◇◇□■● ◆▲○◆ - △ ▲◇◇△□○○●△ ○◆◇▲ - ■ ◇◇△●●◆ ◆○▲◇○○◇□▲ - △□■●○□■◇□▲ □△■□△□◇◇ - ◇ ◇◇◇■○◇○●◆◆ ◇■◇□◇◇◇ - ◆ ◇□○◇□□◇○○▲■△▲△ ◆◇◇ - ◇ ◇△◆▲○◇◇◇◆△●○◆◇ ◇◇ - ■▲◇■ △◇■◇◇◇◇□◇◇▲◇◇◆ - ◇◆◇◇◇◆▲◇○◇◆◇◇◇◇◆◆◇◇◇ - ■ ◆○△○○◇△■□□□□◇◇■△○◇ - ○◇◇◇○▲◆ ▲△ ○△◇◆ - ○□○◇○○ △●◆●◇△□ - ○▲■○◆◇□△□●●●□◇◆ - ■▲◆ □○○◇●□△■ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_1.txt b/codex-rs/tui2/frames/slug/frame_1.txt deleted file mode 100644 index 514dc8ac49c..00000000000 --- a/codex-rs/tui2/frames/slug/frame_1.txt +++ /dev/null @@ -1,17 +0,0 @@ - - d-dcottoottd - dot5pot5tooeeod dgtd - tepetppgde egpegxoxeet - cpdoppttd 5pecet - odc5pdeoeoo g-eoot - xp te ep5ceet p-oeet - tdg-p poep5ged g e5e - eedee t55ecep gee - eoxpe ceedoeg-xttttttdtt og e - dxcp dcte 5p egeddd-cttte5t5te - oddgd dot-5e edpppp dpg5tcd5 - pdt gt e tp5pde - doteotd dodtedtg - dptodgptccocc-optdtep - epgpexxdddtdctpg - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_10.txt b/codex-rs/tui2/frames/slug/frame_10.txt deleted file mode 100644 index bd3b8fafff4..00000000000 --- a/codex-rs/tui2/frames/slug/frame_10.txt +++ /dev/null @@ -1,17 +0,0 @@ - - dtpppottd - ppetptox5dpt - ddtee5xx-xtott - edd5oecd-otppoot - 5 ceeged pt5d5e5 - ee pepx55o gedge - o xpgpeexep e5t - g eeot5tee-de-oee - g xo ooecxxtotcee - e teoted5dpdddepe - t geeeeegggotgoee - oeptotpg dxggt55 - ep eeexptct5e5e - cepp5etcdg55p - pt dpodtcp - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_11.txt b/codex-rs/tui2/frames/slug/frame_11.txt deleted file mode 100644 index 9eaf147a6a0..00000000000 --- a/codex-rs/tui2/frames/slug/frame_11.txt +++ /dev/null @@ -1,17 +0,0 @@ - - tppppttd - 5g ceeeoot - 5gddeecop5ot - eddeeoctdo55 - dg-coetopcdeet - eteeetdcced5ee - pp teeeeeedeoo - e ee5eeo5 ege - 5 pe5eep5tede - pp de5otg5eded - pe- eeeeo5eooe - od-5edp5ppcee - gd peooddecg - otgpeetd5pe - od pptdte - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_12.txt b/codex-rs/tui2/frames/slug/frame_12.txt deleted file mode 100644 index 11163a99b9b..00000000000 --- a/codex-rs/tui2/frames/slug/frame_12.txt +++ /dev/null @@ -1,17 +0,0 @@ - - tpppt- - toed5eto - g e5ott - 5txeeooge - e pxeee-5e - ep--pdgdeg - e x5oeeo - e 5toeeg - pt x5eetex - e 5epcpd - e- egopp5 - t pegdte - 5 ppetpoe - pd5gteoee - o pxo-5 - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_13.txt b/codex-rs/tui2/frames/slug/frame_13.txt deleted file mode 100644 index eb072e40ad2..00000000000 --- a/codex-rs/tui2/frames/slug/frame_13.txt +++ /dev/null @@ -1,17 +0,0 @@ - - 5pppt - eddee - eedeg - epped - p ee - gc-ee - t ee - t ge - 5t dx- - eg toe - pe-- xe - eddde - etted - pddeo - t -go - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_14.txt b/codex-rs/tui2/frames/slug/frame_14.txt deleted file mode 100644 index 100f3093023..00000000000 --- a/codex-rs/tui2/frames/slug/frame_14.txt +++ /dev/null @@ -1,17 +0,0 @@ - - tpppc - t5dd-o - edee- e - ee5egdxt - eeeo e - xpee pe - eeee - p - eeoee o - eeex g - gd55 c5 - oeggt-te - epxeddde - 5ggeoooe - eo5pdd5 - dp po5 - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_15.txt b/codex-rs/tui2/frames/slug/frame_15.txt deleted file mode 100644 index 5761f309d46..00000000000 --- a/codex-rs/tui2/frames/slug/frame_15.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ttpppxd - etoeedcpt - 55epooegpe - e5e 55t-dde - eeogooee5gde - oee-ee55e g - ee eeeexxte - ee5xeteee p-e - eetttpeeed-ce - edec5eoxp- -e - e5eeeede e c - epp-dxeo- o - peot 555e ce - edeeoo-to5 - odpdddd5 - ee \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_16.txt b/codex-rs/tui2/frames/slug/frame_16.txt deleted file mode 100644 index f9001140ed8..00000000000 --- a/codex-rs/tui2/frames/slug/frame_16.txt +++ /dev/null @@ -1,17 +0,0 @@ - - dotgpptxd - 5deepeeoeoo - e55go5ooee po - 555 cpdoeoeeppe - eecd5ppoeep5eeo - eeepep eoo ge x-e - ooe 5eteeee5pgt e - e5c eeee5eeegc ee - ooexetx5deegpt 5 - pgddtooope-de tde - eeetgg5poecgc-xee - eep ee e55t 5 - oep e 5t5dte - oexgdpx55tde - pc-docddcp - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_17.txt b/codex-rs/tui2/frames/slug/frame_17.txt deleted file mode 100644 index 696d932d409..00000000000 --- a/codex-rs/tui2/frames/slug/frame_17.txt +++ /dev/null @@ -1,17 +0,0 @@ - - totttccxtd - dcppexxpopetgdt - tpo5dooettgeeedgo - 5e5d5egde pecoxeeoo - e5d x eg5ooo55 t - eopt5e tc5e 5to5e-5 - pgc5e t55ed pgee5oe - -goeg g55ee5eteeocp - t5p5oootxodeodcoeee e - egdcdde5po5eeogotpto - ooo5gggppppodep55o op - oeedp e5eee c - doogpod tpt5dd5 - t xptootedcpcep - etc5dttxpdtp - e \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_18.txt b/codex-rs/tui2/frames/slug/frame_18.txt deleted file mode 100644 index abb0da53d29..00000000000 --- a/codex-rs/tui2/frames/slug/frame_18.txt +++ /dev/null @@ -1,17 +0,0 @@ - - dootootcxtd - dtgdctttxddtgtccd - d5cepgpogtg-dgt55o p - teep-tdgp poed5oxocooot - do55 5e dgtdo5x5edocood - 5oe ttx-ddd5tptep-5d5e5xg - eeoc5 t5p5egd5gpeoeot - go-xe dogeecd eceg - ogg tooococtxetep5ot epee - dooepop xe5ddodxeedcxeo t - e5eoeggpppppe odd5e5p5e-e - oogtot eepg5pdp - odptdd- dpdd5ote - pe-ppttooodppd5dtp - gxed5ddt-tgctg - e \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_19.txt b/codex-rs/tui2/frames/slug/frame_19.txt deleted file mode 100644 index ffc4d2b4755..00000000000 --- a/codex-rs/tui2/frames/slug/frame_19.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ddootxtoox-d - dteeeo5ooodpdteptt - tpcc5getpe epctepco - 5ceptde doottdept - ee5e5e tedg5geo eot - eo5pp depx5g-5 p-pe - doexp 5pd5ette 5c5te - eeee ecgoegt e eee - epeotoccoooxxxetpcpec o gee - dc5teop5dptotet dd codot5-ed - pog5tegggggppg dod5e55 55p - oodpdo e55d55p - pgdoxxpt tco-5ece - pg-ep5xtddoc5pg cpxp - gx-dc-pdt-dp-d - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_2.txt b/codex-rs/tui2/frames/slug/frame_2.txt deleted file mode 100644 index f4419e3d693..00000000000 --- a/codex-rs/tui2/frames/slug/frame_2.txt +++ /dev/null @@ -1,17 +0,0 @@ - - d-dcotooottd - dtt5pcteexoxeodpeptd - tepeoppxpee egpop5eecet - de5d5ppttd -toe5et - tdg5pdeodood dteoet - p5tge epot5ot ooepe - teppe d5ecedet 5gege - eg oe tepeecp ep5-e - pggoe cedddeg-xtttttttttedexp - dope 5eep 5p eoodddd--ddet5geg - ooo p po--ep egpppppppgetpee - pod-5t e ttc5tp - -oddett todtgdeg - exdcddgptccocc-opedeep - eptptxxddddxc5pg - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_20.txt b/codex-rs/tui2/frames/slug/frame_20.txt deleted file mode 100644 index 0039bd880b1..00000000000 --- a/codex-rs/tui2/frames/slug/frame_20.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ddtoxxototdd - tcogctdtoooeeddpott - ttgdtpgxpg egdgotpetd - degdpep dtteo5poo - de tep d5gdeedpoeeo - 5etep tppceg5 poxeo - cecte tp -tpd toe5 - edd5e ggccegt exge - e5eectocoooooodtpcddoop 5 eo - pededg 5eeddddeo poogeoo t5 ee - otdopgggggggpg otoeete 5o - p dp5t d5p-e5 - oddptxd tcpecpp - dt--gxtdcctcgxget5pp - eptxdgoddddepgg - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_21.txt b/codex-rs/tui2/frames/slug/frame_21.txt deleted file mode 100644 index 87e3597d5d8..00000000000 --- a/codex-rs/tui2/frames/slug/frame_21.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ddtotootottdd - ttpeeddtxoxtcde-ptd - cpddtpdge edxptdept - tpecgp dtcptcpt - 5t5pe te5do ooddt - 5g5e tppd55 oodt - epee dg5et5p ocog - eo oc get e e - e e ttcccccttt detget c5 e - e xo ed dte dgepet 5g-5 - c g- eeggg pe ppdtc 5e t - pt ccd dpg 5 - pd d-d d-cpp te - pod pgptcxxccopgg -e - pttctddddtdctpe - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_22.txt b/codex-rs/tui2/frames/slug/frame_22.txt deleted file mode 100644 index 8dfe7daaab6..00000000000 --- a/codex-rs/tui2/frames/slug/frame_22.txt +++ /dev/null @@ -1,17 +0,0 @@ - - dttotootottd - dt5pe-d5e5oeecet5ptd - tcpcxoppe egpopptddtt - cpxppg tteeedpo - oex5p td5eoe-5cpe - oep5e tx5ecd5e oeooe - etpo5 xteop5p xe5e - eeoee eoexdo edege - eetgt txoocccottdopedgot decgg - deo pdootg5tgx55e opcdgettg5oo - ooode gggggppge ecptd555gte - opodot dptgetp - pgtc ttd d-ptgpd5 - pcpttgpxcotcc5opggptp - gxgxdcddd-od-ope - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_23.txt b/codex-rs/tui2/frames/slug/frame_23.txt deleted file mode 100644 index f573acb7142..00000000000 --- a/codex-rs/tui2/frames/slug/frame_23.txt +++ /dev/null @@ -1,17 +0,0 @@ - - dttdotpttotdd - doeteodtdootedepetd - tgteptcpe gxcteoceet - 5cepc5e dccoeeco - 55ee5p t5ttpp5poeo - toeg5e dppppp5ppexoet - eeege 5c5- dee ggepp - x5e e egetdot e p5e - dgo etxcooooocoedpedgod e exe - eoodegog 5eo oedotx ode - 5pe e eggggggg pe5coed5d5e - teede- t5eod5e - o etp5dd d-gcp5t5 - oootcptoxoodttg-dtpe - gxgpooxddtddppe - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_24.txt b/codex-rs/tui2/frames/slug/frame_24.txt deleted file mode 100644 index 92833e8c589..00000000000 --- a/codex-rs/tui2/frames/slug/frame_24.txt +++ /dev/null @@ -1,17 +0,0 @@ - - dxdcttpootd - tgd5geddxoottdpt - tgpco5tgx -ecpxppt o - tp55t5eotoex5egdpoeodp - t 55o5t egg5odeeeoetp - xtotoe ptt5g-ecg5go - e exg t5dt5g5doeoded - e oee eto5odexd5-eee - e- eccccttxxxttdptde e ee - d 5 gpe5ttcctte-dotpe epe - o poeopgggggge ot-poeo5te - o otpo egg5c5 - o-poee- dogd5cdp - --ptodgxxcoocedd5e - pcdptcoddootp - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_25.txt b/codex-rs/tui2/frames/slug/frame_25.txt deleted file mode 100644 index d8b8655dacf..00000000000 --- a/codex-rs/tui2/frames/slug/frame_25.txt +++ /dev/null @@ -1,17 +0,0 @@ - - dtopcttttd - tgptpedcoepeet - 5e55ttg-etoooeeed - etpe 5g oe goetpo5 - tddot5pdc5deg e55o5p - otxexdpt-dec 5ete55et - e5epe edd5od5eo5dgeoe - eee5e-ggxdoo5eodxoeeo - dtoegeddooootxeooetpeo - 5-ge5etedeecpdeopo5oe - oxp5oeegggggpt5eoe5ee - edgectco-tpcd5t55e5 - dededodpc5td5dee5 - o-coodpoeppgpep - xtgpottdtep - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_26.txt b/codex-rs/tui2/frames/slug/frame_26.txt deleted file mode 100644 index 4be73d44de0..00000000000 --- a/codex-rs/tui2/frames/slug/frame_26.txt +++ /dev/null @@ -1,17 +0,0 @@ - - dcpppgtt - 5pec5oetcet - 5pggecoeoggot - cpgteexdeedt5d - edtoe-xgpo5ceoc - o ge5c5gpdogoo5 - eg ppoee5eeccgt - 5- oeeeddoee5ee - e -opctxtoo5oee - g -de5teddtpoope - eeg5epgot5etoee - odxe5o 55geee - p peo de5e5t - egpoogpdppc - o5cdpxdop - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_27.txt b/codex-rs/tui2/frames/slug/frame_27.txt deleted file mode 100644 index f333909d2b7..00000000000 --- a/codex-rs/tui2/frames/slug/frame_27.txt +++ /dev/null @@ -1,17 +0,0 @@ - - cppptt - ecc5e5o - cpe pe5pe - exxdeecex - e eed-po - xd-dgeeeee - o geedpeeg - e eeexogee - e- -geteeee - po -gdedpee - e- ddppt5p - eetteed5e - eootot5ed - oddeoo55 - pog do5 - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_28.txt b/codex-rs/tui2/frames/slug/frame_28.txt deleted file mode 100644 index 3c0deb542c8..00000000000 --- a/codex-rs/tui2/frames/slug/frame_28.txt +++ /dev/null @@ -1,17 +0,0 @@ - - 5ppc - etdee - o cee - e-epe - e xe - e -ge - dex de - e-gge - 5o de - ee-cxe - e de - eotoe - eopee - pdd5e - x -te - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_29.txt b/codex-rs/tui2/frames/slug/frame_29.txt deleted file mode 100644 index 0c6277f4d52..00000000000 --- a/codex-rs/tui2/frames/slug/frame_29.txt +++ /dev/null @@ -1,17 +0,0 @@ - - tppppt - tep5gpo - dtee pge - eeeedot5 - o xge e - etxee dd - eooeee d - eeoxe - eexpe e - deoee e - pee5ed o - e5xxe de - xeeeexde - eoep5gep - xep -t - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_3.txt b/codex-rs/tui2/frames/slug/frame_3.txt deleted file mode 100644 index b1e91736085..00000000000 --- a/codex-rs/tui2/frames/slug/frame_3.txt +++ /dev/null @@ -1,17 +0,0 @@ - - d-octtooootd - dtt5oeetegooecddeptd - tc5pcepgge egxgppt5det - 5t5oecttd eopgeeo - p5pe5d5eepod odoeed - 5eoo5 -teocoo e ooe - op e ppoedget -p5et - t-ete t-eg5oe xdeoe - -gpe ceptxep-xottdddttdxdgce - tocot -5p5cce epeddtgo-tcoeeoee - pde e geettg gpppgggppt555t5 - ppx ot e 5dtd55 - od55pot ttc5gtep - odttdppdococtcopcedtg - eptpdcxddddxc5dg - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_30.txt b/codex-rs/tui2/frames/slug/frame_30.txt deleted file mode 100644 index 9dfd28bc20d..00000000000 --- a/codex-rs/tui2/frames/slug/frame_30.txt +++ /dev/null @@ -1,17 +0,0 @@ - - dcgpptt - d5ceeoppoo - gpdetooetto - eeoe5edoeo ot - opeeeoetet e - epedt peeee-e - o5eeeod o5oe oe - ge ptoxtege- e - gedgoxoxo5et e - geeoeddxegtt e - goeecodpxeetxe - ep55t5poeeg e - oeoee5pxetx5 - tdttod5et5p - o--edddcp - eeee \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_31.txt b/codex-rs/tui2/frames/slug/frame_31.txt deleted file mode 100644 index 1dba8edd8f7..00000000000 --- a/codex-rs/tui2/frames/slug/frame_31.txt +++ /dev/null @@ -1,17 +0,0 @@ - - d-cptptot - dtpcttdgtoppt - teo55tode-gedpo - tx5tddpcdtooeoxeo - deeeet-podtgoe5dd - cedexdepgocpt-5etge - 5ee edpeo-o5cpepe5e - oot exgeo edggexo-e - eotdepdxex5txxed e - geex55eedddodeoee p - dpd5tet 5pppe5epxdg - eeeooot dop e - cepodgt - epe5e - ceoe5deetegtee - pgoxdtp5-cp - eeee \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_32.txt b/codex-rs/tui2/frames/slug/frame_32.txt deleted file mode 100644 index 33160e71634..00000000000 --- a/codex-rs/tui2/frames/slug/frame_32.txt +++ /dev/null @@ -1,17 +0,0 @@ - - tttttccxtd - cpxppxoeeppext - 5dpodttdc-epepoppt - p55edtec- - tdoettgt - 55etepoppec petxo5opot - o5ootopeeceetd et5 p - pegctepoeettetoe5d55epd - eeetd gdeeg5pec-dgoegge - oee-d pdegddetttxxxoegoe - pggdeepopodddddeepecx-d - topee5 epggpppdc555-5 - otedoo toe5px - pppeextd d5de5 t - o-ogxtecopedtgd5 - xcoddtt5pdgg - eee \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_33.txt b/codex-rs/tui2/frames/slug/frame_33.txt deleted file mode 100644 index ff8827f3d2f..00000000000 --- a/codex-rs/tui2/frames/slug/frame_33.txt +++ /dev/null @@ -1,17 +0,0 @@ - - dttototox-d - dtpeeopoxce-epep- - d5oetpttoge gp5eetgt - to5e5detd peeodp - d-e5eee5odo etood - 55t odooeot 5o5do - eet g otpedeo epee - pett tppo5ot ogep - eee e te 5ptttcootxxt5deox - d5epg5ct5exedddddeepdddxe- - ooot e5e5 5 ggggppp eet5de - otoptgt 55c5o5 - pogptpct dtet5pop - oxgpotetctdgctopxp - goottddddtpc-g - eeee \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_34.txt b/codex-rs/tui2/frames/slug/frame_34.txt deleted file mode 100644 index 4b1eb6a5a23..00000000000 --- a/codex-rs/tui2/frames/slug/frame_34.txt +++ /dev/null @@ -1,17 +0,0 @@ - - dtottttoxtd - dtpt5ocegxtpoppctod - d55tepgcxpe edootgppt - t5depd5td petpoo - 55eo-5egeggt oopod - t5oee5 oogeopo otoeo - epdxd podpedd ecxd - oeogc epde5ge 5 do - tp5-g 5o55gdoottttttxddg xde - eeot5ttdg55pxeedx-dddt5deeeeo - 5dot eoepdg gppeeeed t5toep - 5tocood 5c-e5p - oteetoct dtogd5te - -o5xgettcttopopetpcp - gxocodddddcopod - eee \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_35.txt b/codex-rs/tui2/frames/slug/frame_35.txt deleted file mode 100644 index f2432dc0adf..00000000000 --- a/codex-rs/tui2/frames/slug/frame_35.txt +++ /dev/null @@ -1,17 +0,0 @@ - - dttcotttottd - tpop-xpptgepxcgxpod - d55ectpedpge egpop-gept - t55t5tctd ptdoed - 55xe55o gopt eopet - c5c5te pedg--pd eooe - o g-5 oo ppd- edoo - xexc 5ooc5p 5 g5 - 5 td tee5cg5eoodcdotxx exop - etdcp 55cgpt5ec otdddd5gx5p ep - 5eotp5ode5de egddddpddp55dte - g-do5x d5p td - ocedctct tc5dppp - gddgxpx-cxxtxc5pgd5cg - gdxcodd5ddttpgd - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_36.txt b/codex-rs/tui2/frames/slug/frame_36.txt deleted file mode 100644 index c84a104e4ac..00000000000 --- a/codex-rs/tui2/frames/slug/frame_36.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ddtottttottd - doggot5c5totcttgpptd - topottp-pgee egpxptetpet - degptdddd ppxoge - t5dcopeoeot- do-p - 5 t5e pd ge5t godp - e cge go goo edet - eeox do d55g oe e - epge 55 tpgptttdtttttd eoxe - dpeo tedd5x5 gexdddddddee o5pe - p peo tdt5d gppdddddg etg5 - ptgoc- t5eg5 - o5eetxt tttg5te - ptdgppodcxdtxcg-gtctp - ept5xdttdttttppg - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_4.txt b/codex-rs/tui2/frames/slug/frame_4.txt deleted file mode 100644 index 2eed2c84653..00000000000 --- a/codex-rs/tui2/frames/slug/frame_4.txt +++ /dev/null @@ -1,17 +0,0 @@ - - d-octootottd - d-gtpe5geoo5pceg5ptd - tpoeeetpge edxodpe5ood - 55eteg-tt geppopo - 5e5deoeocdet ogoepo - 5eede pdee5po p ooet - goece pppotget t gce - e-o te5pdee eee - deged ce5gd55txtttddddtt eep - eo5ge t555tdeeooet-dtc5dce55ge - eecpotooto5 gppppppppd-eeee - teogede tdc5ppp - ootcdgtd d-dtpce5 - xeeodppoccocttoptoepe - pooxcxdddddc-pe - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_5.txt b/codex-rs/tui2/frames/slug/frame_5.txt deleted file mode 100644 index e0c7693a9ec..00000000000 --- a/codex-rs/tui2/frames/slug/frame_5.txt +++ /dev/null @@ -1,17 +0,0 @@ - - d-occtooottd - dtgt5p5eetxotcdegtt - pd5otepge gdoepogtpt - te5e5e-ot -deeeed - to5p5ddtedot e oeoed - xeee5 odeoc5ed t5ee - e5egt eodeoget ppxtet - e5edg tdoe5de tede - etedp 5degtepodxtxdddttdge-e - doeott5tpg egg55oodto-t5e5pee - oteetxtoo5te gppgggggtxceo5 - oot5oo e 5d5ptd - d5poeotd dottppcp - depetptocxodttopdxcp - d-pdpgddd-dttpe - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_6.txt b/codex-rs/tui2/frames/slug/frame_6.txt deleted file mode 100644 index d5ac091f39c..00000000000 --- a/codex-rs/tui2/frames/slug/frame_6.txt +++ /dev/null @@ -1,17 +0,0 @@ - - d-occtoottdd - tdc5peoptettcdtptd - ttteeepg egx-optp5od - 5tepepdot oteeeet - poeeepootpo -ooeot - c-5e5edtceodet -oo5e - d txe gdoe5do dtede - x exe deox5ee xtoee - d-e-e 5peepc5tdxtxddttd-o5e - eeot5dddct e5opteetcoooeteog - 5 e 5xeec5g gpgggppgeoo5e - t c 5te tcd55ee - -eodppt dtdd5ptt - egxptgtgcxddttppgccp - d-cpttdddedttp - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_7.txt b/codex-rs/tui2/frames/slug/frame_7.txt deleted file mode 100644 index 02d1f1ae521..00000000000 --- a/codex-rs/tui2/frames/slug/frame_7.txt +++ /dev/null @@ -1,17 +0,0 @@ - - d-xcptoottd - tpetoetptooocgept - p teeepe edxegp5dpt - ooeeexct dxope5t - epepeede5ot - peeo - e ceeg epoceo t-eee - eoge ddeoeoe ppdee - dg5xe ot5epee p eee - 5gxp tgd5eo5xxccdxxocpoee - etpeeocxe5pe-oeeoototcoeoe - o od5pepd5 ppppppggd5ee - pd 5d5de tg-5c5p - pcttoood tdxtp5pp - odpoepocxdddtpept5 - gxpgxcxddtdcpp - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_8.txt b/codex-rs/tui2/frames/slug/frame_8.txt deleted file mode 100644 index d028ab360ee..00000000000 --- a/codex-rs/tui2/frames/slug/frame_8.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ddtdcttottd - tgp5eeepoogx gt - deote55pgtgx5xpotdt - dpop5ette p odeeo - p 5e5toe5o -poeet - epteodddoedp oteee - epeee pdcoeee ede5et - otee5 eto5etp- -e5ee - o oeedd g5poex5tttttoxee - g op5odegteteeceoddeeop - 55ooeoeee5pdpdpgopg5oe - ptgeoeee -t555p - podpoood d5odet5p - -cpetpgcctpc5otee - xxdppoedtt5oe - ee \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_9.txt b/codex-rs/tui2/frames/slug/frame_9.txt deleted file mode 100644 index 2481e07a357..00000000000 --- a/codex-rs/tui2/frames/slug/frame_9.txt +++ /dev/null @@ -1,17 +0,0 @@ - - -odp5ttot - 5pd5eepgogd-od - 5 tee5pddo5godxt - g ee5cod ddteodett - 5pgcopgept p-ptctee - e eeegdeocdd epepeee - e xpoeppeootg-t5 eee - e x5dtoxeed5oode gee - g gteg 5egexxetexteee - edeeedtededeeeeddeee - g ed5ooe5gppppeeg5oe - oxeeote t5 d5ee - otoeoo 5cdce5p - d-godep5toccpee - p-d pooect5g - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_1.txt b/codex-rs/tui2/frames/vbars/frame_1.txt deleted file mode 100644 index 0ca3a5d334c..00000000000 --- a/codex-rs/tui2/frames/vbars/frame_1.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▎▋▎▋▌▉▉▌▌▉▊▎ - ▎▌▊▋▉▍▉▋▉▍▌▏▏▌▎ ▎█▉▎ - ▊▏▉▏▉▉▉█▍▎ ▎█▉▎█▏▌▏▏▏▉ - ▌▉▎▍▉█▊▊▎ ▋▉▏▌▏▊ - ▍▍▌▋█▍▏▍▎▍▍ █▋▏▍▍▊ - ▏▉ ▉▎ ▏▉▋▌▏▏▊ █▋▍▏▏▊ - ▉▍█▊▉ █▍▏▉▋█▏▎ ▏▋▏ - ▏▏▎▏▎ ▊▋▋▏▌▏▉ █▎▏ - ▏▌▏█▎ ▌▏▏▍▍▏█▋▏▉▉▉▉▉▉▎▉▊ ▌█ ▏ - ▎▏▌▉ ▎▌▉▎ ▋▉ ▏█▏▎▎▎▋▋▊▊▊▏▋▊▋▊▏ - ▍▍▎█▍ ▍▍▊▋▋▎ ▎▍▉██▉ ▍▉█▋▊▌▎▋ - ▉▍▊ █▊ ▎ ▊█▋▉▎▏ - ▍▍▊▎▍▉▎ ▎▌▎▉▏▎▉█ - ▍▉▊▍▎ ▉▉▋▌▌▌▌▋▌▉▉▎▊▏▉ - ▎▉█▉▏▏▏▎▎▎▊▎▌▉▉█ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_10.txt b/codex-rs/tui2/frames/vbars/frame_10.txt deleted file mode 100644 index b422fb1274e..00000000000 --- a/codex-rs/tui2/frames/vbars/frame_10.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▎▉▉▉▉▍▉▉▎ - ▉█▎▉▉▉▍▏▋▎▉▊ - ▍▎▊▏▏▋▏▏▊▏▉▍▊▊ - ▏▎▎▋▍▏▌▎▋▍▊██▍▍▊ - ▋ ▌▏▏█▏▍ █▊▋▎▋▏▋ - ▏▎ █▏▉▏▋▋▍ ▎▎█▏ - ▍ ▏▉█▉▏▏▏▏▉ ▏▋▊ - █ ▏▏▍▉▋▉▏▏▊▎▎▋▍▏▏ - █ ▏▍ ▍▍▏▌▏▏▉▍▉▌▏▏ - ▏ ▊▏▍▊▏▎▋▎▉▎▎▎▏▉▎ - ▊ █▏▏▏▏▏██ ▍▊█▍▏▎ - ▍▎█▊▍▊▉█ ▎▏██▊▋▋ - ▏█ ▏▏▏▏▉▊▋▊▋▏▋▎ - ▌▎▉▉▋▏▉▌▎ ▋▋█ - ▉▊ ▎▉▍▎▊▌▉ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_11.txt b/codex-rs/tui2/frames/vbars/frame_11.txt deleted file mode 100644 index 5d4524e2938..00000000000 --- a/codex-rs/tui2/frames/vbars/frame_11.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▊▉▉▉▉▉▉▎ - ▋█ ▌▏▏▏▍▍▊ - ▋█▎▎▏▏▌▍▉▋▍▊ - ▏▎▎▏▏▌▌▊▎▌▋▋ - ▎█▋▌▍▏▉▍█▌▎▏▏▊ - ▏▉▎▏▏▉▎▌▌▏▎▋▏▏ - ▉▉ ▊▏▏▏▏▏▏▎▏▌▍ - ▏ ▏▏▋▏▏▍▋ ▏█▏ - ▋ █▏▋▏▏▉▋▉▏▎▏ - ▉▉ ▎▏▋▌▊█▋▏▎▏▍ - █▎▊ ▏▏▏▏▌▋▏▍▍▏ - ▍▎▊▋▏▎▉▋▉▉▌▏▎ - ▎ ▉▏▍▍▍▎▏▌█ - ▍▉ ▉▏▏▊▎▋▉▎ - ▍▎ █▉▉▎▉▎ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_12.txt b/codex-rs/tui2/frames/vbars/frame_12.txt deleted file mode 100644 index f81900edb1c..00000000000 --- a/codex-rs/tui2/frames/vbars/frame_12.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▉▉▉▉▉▋ - ▊▌▎▎▋▏▊▍ - █ ▏▋▍▉▊ - ▋▉▏▏▏▌▌█▏ - ▏ █▏▏▏▏▋▋▏ - ▏█▋▋▉▍█▎▏█ - ▏ ▏▋▍▏▏▍ - ▏ ▋▉▍▏▏█ - ▉▊ ▏▋▏▏▉▏▏ - ▏ ▋▏▉▌▉▎ - ▏▋ ▏█▌▉▉▋ - ▊ ▉▏ ▎▊▏ - ▋ ▉▉▏▊▉▍▏ - █▍▋█▊▏▍▏▎ - ▍ █▏▍▋▋ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_13.txt b/codex-rs/tui2/frames/vbars/frame_13.txt deleted file mode 100644 index 4231032a45c..00000000000 --- a/codex-rs/tui2/frames/vbars/frame_13.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▋▉▉▉▊ - ▏▎▎▏▏ - ▏▎▎▏█ - ▏▉▉▏▎ - ▉ ▏▏ - █▋▊▏▏ - ▊ ▏▏ - ▉ █▏ - ▋▉ ▎▏▋ - ▏█ ▉▌▏ - █▎▋▋ ▏▎ - ▏▎▎▎▏ - ▏▉▊▏▎ - ▉▎▎▏▌ - ▊ ▋█▌ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_14.txt b/codex-rs/tui2/frames/vbars/frame_14.txt deleted file mode 100644 index 6eab794e0ab..00000000000 --- a/codex-rs/tui2/frames/vbars/frame_14.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▉▉▉▉▌ - ▊▋▎▎▋▍ - ▏▎▏▏▋ ▏ - ▏▏▋▏█▍▏▊ - ▏▏▏▍ ▏ - ▏▉▏▏ █▏ - ▏▏▏▏ ▋ ▉ - ▏▏▍▏▎ ▍ - ▏▏▏▏ █ - █▍▋▋ ▌▋ - ▍▏██▊▋▊▏ - ▏▉▏▏▎▎▎▏ - ▋ █▏▌▌▌▎ - ▏▍▋▉▎▎▋ - ▎▉ █▌▋ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_15.txt b/codex-rs/tui2/frames/vbars/frame_15.txt deleted file mode 100644 index fa9a859bd04..00000000000 --- a/codex-rs/tui2/frames/vbars/frame_15.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▉▉▉▉▉▏▎ - ▎▊▌▏▏▍▋▉▊ - ▋▋▏▉▌▍▏█▉▏ - ▏▋▎ ▋▋▊▋▎▎▏ - ▏▏▌█▌▍▏▏▋█▍▏ - ▍▏▏▊▏▏▋▋▏ █ - ▏▏ ▏▏▏▏▏▏▊▏ - ▏▏▋▏▏▉▏▏▏ █▊▏ - ▏▏▉▊▊▉▏▏▏▎▋▋▏ - ▏▎▏▌▋▏▍▏▉▋ ▋▏ - ▏▋▏▏▏▏▎▏ ▎ ▌ - ▏▉▉▋▍▏▏▍▊ ▌ - █▏▍▊ ▋▋▋▎ ▌▎ - ▏▍▏▏▍▍▋▉▍▋ - ▍▍▉▍▎▎▎▋ - ▎▎ \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_16.txt b/codex-rs/tui2/frames/vbars/frame_16.txt deleted file mode 100644 index 1fcc2090a21..00000000000 --- a/codex-rs/tui2/frames/vbars/frame_16.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▎▌▉█▉▉▉▏▎ - ▋▍▏▏▉▏▏▍▏▌▍ - ▏▋▋█▍▋▌▍▏▏ █▍ - ▋▋▋ ▌▉▎▍▏▍▏▏▉▉▏ - ▏▏▌▎▋▉█▌▏▏▉▋▏▎▍ - ▏▏▏▉▏█ ▏▍▌ ▏ ▏▋▏ - ▍▍▏ ▋▎▊▏▏▏▏▋▉█▊ ▏ - ▏▋▌ ▏▎▏▏▋▏▏▏█▌ ▎▏ - ▍▍▏▏▏▉▏▋▍▏▏█▉▉ ▋ - ▉█▎▎▊▌▌▍▉▏▋▎▏ ▊▎▏ - ▏▏▏▉██▋▉▍▏▌█▌▋▏▏▎ - ▏▏▉ ▏▎ ▎▋▋▊ ▋ - ▍▏▉ ▏ ▋▊▋▎▊▏ - ▍▏▏█▎▉▏▋▋▉▎▏ - █▋▋▎▌▋▎▎▌▉ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_17.txt b/codex-rs/tui2/frames/vbars/frame_17.txt deleted file mode 100644 index 1adf01af903..00000000000 --- a/codex-rs/tui2/frames/vbars/frame_17.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▊▌▉▉▉▌▌▏▊▎ - ▎▌▉▉▏▏▏▉▌▉▏▊█▍▊ - ▊▉▍▋▎▌▌▎▉▊█▏▏▏▎█▍ - ▋▏▋▎▋▏█▎▎ █▏▌▍▏▏▏▌▍ - ▏▋▎ ▏ ▏█▋▍▌▍▋▋ ▊ - ▎▌▉▊▋▎ ▊▋▋▏ ▋▊▌▋▏▋▋ - ▉ ▌▋▏ ▊▋▋▏▎ ▉█▏▏▋▌▏ - ▊█▌▏ ▋▋▏▏▋▏▊▏▏▌▌▉ - ▊▋▉▋▍▌▌▉▏▍▎▏▍▍▋▍▏▏▏ ▏ - ▏█▎▌▎▎▏▋▉▍▋▏▏▍ ▍▉█▉▍ - ▍▍▍▋███▉▉▉▉▍▎▏▉▋▋▍ ▍█ - ▍▏▏▎▉ ▎▋▏▏▎ ▌ - ▎▍▍ ▉▍▎ ▊█▊▋▍▎▋ - ▊ ▏▉▉▌▍▉▎▎▌▉▋▏█ - ▎▉▌▋▎▊▉▏▉▎▉▉ - ▎ \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_18.txt b/codex-rs/tui2/frames/vbars/frame_18.txt deleted file mode 100644 index 9c46c648214..00000000000 --- a/codex-rs/tui2/frames/vbars/frame_18.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▎▌▌▉▌▌▉▌▏▊▎ - ▎▉█▎▌▊▉▉▏▎▎▊█▉▌▌▎ - ▎▋▋▏▉█▉▌█▊█▋▎█▉▋▋▍ ▉ - ▊▏▏▉▋▊▍█▉ █▍▎▎▋▌▏▍▌▍▍▍▊ - ▎▍▋▋ ▋▏ ▎█▉▎▍▋▏▋▏▎▍▌▍▍▎ - ▋▍▏ ▊▊▏▋▎▎▎▋▊▉▊▏█▊▋▍▋▎▋▏ - ▏▏▍▋▋ ▉▋▉▋▏█▎▋█▉▏▌▏▌▊ - █▍▊▏▏ ▍▍█▏▏▌▍ ▏▌▏█ - ▍██ ▊▍▌▌▌▌▌▉▏▏▊▏▉▋▍▊ ▏▉▏▎ - ▎▍▍▏▉▍▉ ▏▏▋▎▎▍▎▏▏▏▎▌▏▎▍ ▊ - ▏▋▏▍▎██▉▉▉▉▉▎ ▍▎▎▋▏▋▉▋▎▊▎ - ▍▍ ▊▍▊ ▎▎▉█▋▉▎█ - ▍▍▉▉▍▍▋ ▎▉▍▎▋▍▊▎ - █▏▋▉▉▉▉▌▌▌▍█▉▎▋▎▊▉ - █▏▏▎▋▎▎▊▋▉█▌▉█ - ▎ \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_19.txt b/codex-rs/tui2/frames/vbars/frame_19.txt deleted file mode 100644 index 572f5ffc324..00000000000 --- a/codex-rs/tui2/frames/vbars/frame_19.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▎▎▌▌▉▏▉▌▌▏▋▎ - ▎▉▏▏▏▍▋▌▌▌▎▉▎▊▏▉▊▊ - ▊▉▌▌▋█▏▉▉▎ ▎▉▋▊▎▉▌▍ - ▋▌▏▉▊▍▎ ▎▌▌▉▊▍▏▉▊ - ▏▏▋▎▋▎ ▊▏▍█▋█▏▍ ▏▍▊ - ▏▍▋▉█ ▎▏▉▏▋ ▋▋ █▋▉▏ - ▍▌▏▏█ ▋▉▍▋▏▉▊▎ ▋▋▋▉▏ - ▏▏▏▏ ▏▌█▍▏█▊ ▏ ▏▏▏ - ▏▉▏▍▊▌▌▌▌▌▌▏▏▏▏▉▉▌▉▏▌ ▍ █▏▏ - ▍▌▋▊▏▌▉▋▍▉▉▍▊▎▊ ▍▎ ▋▍▎▍▊▋▋▏▎ - █▍█▋▉▏█████▉▉█ ▍▍▎▋▏▋▋ ▋▋█ - ▍▍▍▉▎▍ ▎▋▋▎▋▋█ - █ ▎▍▏▏▉▊ ▊▌▌▋▋▏▌▎ - ██▋▏▉▋▏▉▎▎▌▌▋▉█ ▌▉▏█ - █▏▋▎▋▊█▎▊▋▍▉▊▍ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_2.txt b/codex-rs/tui2/frames/vbars/frame_2.txt deleted file mode 100644 index 0e0c021f436..00000000000 --- a/codex-rs/tui2/frames/vbars/frame_2.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▎▋▎▋▌▉▌▌▌▉▊▎ - ▎▉▊▋▉▌▉▏▏▏▌▏▏▌▎█▏▉▉▎ - ▊▏▉▏▍▉▉▏▉▎▎ ▎█▉▌▉▋▏▏▌▏▊ - ▎▏▋▎▋▉█▊▊▎ ▊▊▍▏▋▏▊ - ▊▍█▋▉▍▏▍▎▍▍▎ ▍▊▏▍▏▊ - █▋▉█▎ ▏▉▍▉▋▍▉ ▍▍▏▉▏ - ▊▏█▉▏ ▍▋▏▌▏▎▏▊ ▋█▏█▏ - ▏█ ▍▎ ▊▏▉▏▏▌▉ ▏█▋▋▏ - ▉██▌▏ ▌▏▍▍▎▏█▋▏▉▉▉▉▉▉▉▉▊▏▎▏▏▉ - ▎▌█▏ ▋▏▏█ ▋▉ ▏▌▍▎▎▎▎▋▋▎▎▏▉▋ ▏ - ▍▍▍ ▉ ▉▍▋▋▏█ ▎█▉▉▉▉▉▉▉█▏▊▉▏▏ - █▍▎▋▋▊ ▎ ▊▉▌▋▊▉ - ▋▍▎▎▏▉▊ ▊▌▎▉ ▎▏█ - ▎▏▍▌▎▎█▉▉▋▌▌▌▌▋▌▉▎▎▏▏▉ - ▎▉▉▉▉▏▏▎▎▎▎▏▌▋▉█ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_20.txt b/codex-rs/tui2/frames/vbars/frame_20.txt deleted file mode 100644 index 42c288df929..00000000000 --- a/codex-rs/tui2/frames/vbars/frame_20.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▎▎▉▌▏▏▌▉▌▊▎▎ - ▊▌▌█▌▊▎▊▌▌▌▏▏▎▍▉▍▉▊ - ▊▊█▎▉▉█▏▉█ ▎█▍█▍▉▉▏▉▎ - ▎▏█▎▉▏▉ ▎▊▉▏▍▋▉▍▍ - ▎▏ ▊▏▉ ▎▋ ▎▏▏▍▉▍▏▏▍ - ▋▎▊▏█ ▉▉█▌▏█▋ █▍▏▏▍ - ▌▏▌▉▏ ▊▉ ▋▉▉▍ ▊▍▏▋ - ▏▎▍▋▎ ██▌▋▏█▊ ▏▏█▏ - ▏▋▏▏▌▊▌▌▌▌▌▌▌▌▎▊█▌▍▍▍▍▉ ▋ ▏▍ - █▏▍▏▎█ ▋▎▎▎▎▎▎▏▍ ▉▍▍█▏▌▍ ▊▋ ▏▏ - ▍▊▍▍████████▉█ ▍▊▌▏▏▊▏ ▋▍ - ▉ ▍▉▋▊ ▎▋▉▋▏▋ - ▍▎▍▉▊▏▎ ▊▌▉▎▌▉█ - ▍▊▊▋█▏▉▍▌▌▊▋█▏█▎▊▋▉█ - ▎▉▉▏▎ ▌▎▎▎▎▏▉██ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_21.txt b/codex-rs/tui2/frames/vbars/frame_21.txt deleted file mode 100644 index aa5d4f7274c..00000000000 --- a/codex-rs/tui2/frames/vbars/frame_21.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▎▎▊▌▉▌▌▉▌▉▊▎▎ - ▊▉▉▎▎▎▎▉▏▌▏▉▌▎▎▊▉▉▎ - ▌▉▍▎▉▉▍█▎ ▎▍▏▉▉▎▎▉▊ - ▊▉▏▌██ ▎▊▌▉▊▌▉▊ - ▋▊▋▉▎ ▊▏▋▎▍ ▍▍▎▍▊ - ▋█▋▏ ▊▉▉▎▋▋ ▍▍▍▊ - ▏█▏▎ ▎ ▋▎▊▋█ ▍▌▍█ - ▎▍ ▍▌ █▏▊ ▏ ▏ - ▏ ▏ ▊▉▌▌▌▌▌▉▉▉ ▍▏▊█▏▊ ▌▋ ▏ - ▏ ▏▍ ▏▎ ▎▊▏ ▍█▎▉▏▊ ▋█▋▋ - ▌ █▋ ▎▎███ █▎ █▉▎▊▌ ▋▎ ▊ - █▉ ▋▌▎ ▎▉█ ▋ - ▉▎ ▍▊▎ ▎▋▌▉█ ▊▎ - ▉▌▎ ▉█▉▉▋▏▏▌▋▌▉██ ▊▎ - ▉▉▉▌▉▎▎▎▎▊▎▌▉▉▎ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_22.txt b/codex-rs/tui2/frames/vbars/frame_22.txt deleted file mode 100644 index 3b1ce4ecded..00000000000 --- a/codex-rs/tui2/frames/vbars/frame_22.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▎▊▉▌▉▌▌▉▌▉▊▎ - ▎▉▋▉▏▋▎▋▏▋▍▏▏▌▏▉▋▉▉▎ - ▊▌▉▌▏▍▉▉▎ ▎█▉▍▉▉▉▎▎▉▊ - ▌▉▏▉▉█ ▊▊▏▏▏▎▉▍ - ▍▏▏▋█ ▊▎▋▏▌▏▋▋▌█▏ - ▍▏▉▋▎ ▊▏▋▏▌▍▋▎ ▍▏▍▌▏ - ▏▉▉▍▋ ▏▉▏▍▉▋█ ▏▏▋▏ - ▏▏▌▏▎ ▏▍▏▏▍▍ ▏▍▏█▏ - ▏▏▊█▊ ▉▏▌▌▌▌▌▌▉▉▎▍▉▏▎ ▍▊ ▎▏▌█ - ▍▏▌ ▉▎▍▍▊ ▋▊█▏▋▋▏ ▍▉▌▎█▏▊▊ ▋▍▌ - ▍▍▍▎▏ █████▉▉█▎ ▎▋▉▉▎▋▋▋█▊▎ - ▍▉▍▎▍▊ ▎▉▊█▏▊█ - ██▉▌ ▉▉▎ ▎▋▉▊█▉▎▋ - █▋▉▉▊█▉▏▌▌▉▌▌▋▌▉███▊▉ - █▏█▏▎▌▎▎▎▊▌▎▋▌▉▎ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_23.txt b/codex-rs/tui2/frames/vbars/frame_23.txt deleted file mode 100644 index 0b99396129d..00000000000 --- a/codex-rs/tui2/frames/vbars/frame_23.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▎▊▉▎▌▉▉▉▉▌▉▎▎ - ▎▌▏▉▏▍▎▊▎▌▌▊▏▎▏▉▏▉▎ - ▊█▉▏█▊▌▉▎ █▏▋▉▏▍▌▏▏▊ - ▋▌▏▉▌▋▎ ▎▌▌▍▏▏▌▍ - ▋▋▏▎▋█ ▊▋▊▊█▉▋▉▍▏▍ - ▊▍▏█▋▎ ▎▉▉▉██▋▉█▏▏▍▏▊ - ▎▏▏█▏ ▋▋▋▋ ▎▏▎ █ ▏█▉ - ▏▋▏ ▏ ▏█▏▊▎▍▊ ▏ ▉▋▏ - ▍█▍ ▏▉▏▌▌▌▌▌▌▌▌▏▎▉▏▎█▍▎ ▏ ▏▏▏ - ▏▍▍▎▏█▌█ ▋▎▍ ▍▏▎▍▊▏ ▍▎▏ - ▋█▏ ▏ ▎███████ █▎▋▌▌▏▎▋▍▋▎ - ▊▏▏▎▏▋ ▊▋▏▍▍▋▎ - ▍ ▏▊█▋▎▎ ▎▋█▌▉▋▉▋ - ▍▌▍▉▌▉▉▍▏▌▌▎▉▉█▊▎▉▉▎ - █▏ ▉▌▍▏▎▎▉▎▎▉▉▎ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_24.txt b/codex-rs/tui2/frames/vbars/frame_24.txt deleted file mode 100644 index 5e26d7a27bf..00000000000 --- a/codex-rs/tui2/frames/vbars/frame_24.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▎▏▎▌▉▉▉▌▌▊▎ - ▊█▎▋█▏▎▎▏▌▌▉▊▎█▉ - ▊██▌▍▋▉█▏ ▋▎▋▉▏▉▉▊ ▍ - ▊█▋▋▊▋▏▌▊▍▎▏▋▎ ▎▉▍▏▍▎█ - ▊ ▋▋▍▋▊ ▎██▋▍▎▏▎▎▍▏▊█ - ▏▊▍▉▍▎ ▉▊▊▋ ▋▏▌ ▋█▍ - ▏ ▏▏ ▊▋▎▊▋█▋▍▍▏▍▍▏▍ - ▏ ▍▏▏ ▏▊▌▋▍▎▏▏▎▋▋▎▏▏ - ▏▋ ▏▌▌▌▋▉▉▏▏▏▉▉▎▉▊▎▏ ▏ ▏▏ - ▎ ▋ ▉▏▋▊▊▌▌▊▊▏▋▍▍▉▉▏ ▏▉▏ - ▍ █▍▏▍▉██████▎ ▍▊▋▉▍▏▌▋▊▎ - ▍ ▍▊▉▍ ▏ █▋▌▋ - ▍▋▉▍▎▏▋ ▎▌█▎▋▋▎▉ - ▋▊▉▊▍▍█▏▏▋▌▌▌▏▍▎▋▎ - ▉▋▎▉▉▌▍▎▎▌▌▉▉ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_25.txt b/codex-rs/tui2/frames/vbars/frame_25.txt deleted file mode 100644 index 5009b8b66d2..00000000000 --- a/codex-rs/tui2/frames/vbars/frame_25.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▎▉▌▉▌▉▉▉▉▎ - ▊ ▉▊▉▎▎▌▍▏▉▏▏▊ - ▋▏▋▋▊▊█▋▏▉▍▌▍▏▏▏▎ - ▏▉▉▏ ▋ ▌▏ █▍▏▊▉▍▋ - ▊▎▎▍▊▋█▍▌▋▎▏█ ▏▋▋▍▋▉ - ▍▊▏▏▏▎▉▉▋▍▏▋ ▋▏▊▏▋▋▏▊ - ▏▋▏▉▏ ▏▎▎▋▍▎▋▏▍▋▎█▏▌▏ - ▏▏▏▋▏▋█ ▏▎▌▍▋▏▍▎▏▌▏▏▍ - ▍▉▍▏█▏▎▎▌▌▌▌▉▏▏▍▍▏▉▉▏▍ - ▋▊█▏▋▏▊▏▎▏▏▌▉▎▏▍▉▌▋▍▏ - ▍▏▉▋▍▏▎██████▉▋▏▍▏▋▏▎ - ▏▎█▏▌▉▌▍▊▊▉▋▎▋▊▋▋▏▋ - ▍▎▍▏▎▍▎▉▌▋▊▍▋▎▏▏▋ - ▍▋▌▍▍▎▉▌▏▉▉ ▉▏▉ - ▏▊█▉▍▉▊▎▉▏▉ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_26.txt b/codex-rs/tui2/frames/vbars/frame_26.txt deleted file mode 100644 index 900a51c3b55..00000000000 --- a/codex-rs/tui2/frames/vbars/frame_26.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▎▌▉▉▉█▉▊ - ▋▉▏▌▋▍▏▉▌▏▊ - ▋▉██▏▌▍▏▍██▍▊ - ▌▉█▊▏▏▏▎▏▏▎▉▋▍ - ▏▎▊▍▏▋▏█▉▍▋▌▏▍▌ - ▍ █▏▋▋▋█▉▎▍█▍▍▋ - ▏ ▉█▌▏▏▋▏▏▌▌█▉ - ▋▊ ▍▏▏▏▍▍▌▏▏▋▏▏ - ▏ ▋▍▉▌▉▏▉▌▌▋▍▏▏ - ▋▎▏▋▊▏▎▎▊▉▍▍▉▏ - ▏▎█▋▏▉█▍▊▋▎▉▍▏▏ - ▍▎▏▏▋▍ ▋▋█▏▏▎ - ▉ ▉▏▍ ▍▎▋▏▋▊ - ▏█▉▍▍█▉▎▉▉▌ - ▍▋▋▍▉▏▎▍▉ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_27.txt b/codex-rs/tui2/frames/vbars/frame_27.txt deleted file mode 100644 index 0b2e8c7306f..00000000000 --- a/codex-rs/tui2/frames/vbars/frame_27.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▌▉▉▉▉▊ - ▏▌▋▋▏▋▍ - ▌█▎ ▉▏▋█▏ - ▏▏▏▍▏▏▌▏▏ - ▏ ▎▏▎▊█▌ - ▏▎▋▎█▎▏▏▏▎ - ▌ █▏▏▎▉▏▏ - ▏ ▎▏▏▏▌█▏▏ - ▏▋ ▋ ▏▉▏▏▏▏ - █▌ ▋█▎▏▎▉▏▏ - ▏▊ ▎▍▉▉▉▋█ - ▏▏▉▉▏▏▎▋▏ - ▏▌▌▉▌▊▋▏▍ - ▍▎▎▏▍▌▋▋ - █▍█ ▍▍▋ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_28.txt b/codex-rs/tui2/frames/vbars/frame_28.txt deleted file mode 100644 index 01ce82b6d3c..00000000000 --- a/codex-rs/tui2/frames/vbars/frame_28.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▋▉▉▌ - ▏▉▎▏▎ - ▌ ▌▏▎ - ▏▊▎▉▏ - ▏ ▏▏ - ▏ ▊█▏ - ▍▏▏ ▎▏ - ▏▊██▏ - ▋▍ ▎▏ - ▏▏▋▋▏▏ - ▏ ▎▏ - ▏▌▉▌▏ - ▏▌▉▏▏ - ▉▎▎▋▏ - ▏ ▋▉▏ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_29.txt b/codex-rs/tui2/frames/vbars/frame_29.txt deleted file mode 100644 index c682a6082c1..00000000000 --- a/codex-rs/tui2/frames/vbars/frame_29.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▉▉▉▉▉▊ - ▊▏▉▋ ▉▍ - ▍▊▏▏ ██▏ - ▏▏▏▏▍▍▊▋ - ▍ ▏█▏ ▏ - ▏▊▏▏▏ ▎▎ - ▏▍▍▏▏▏ ▍ - ▏▏▌▏▏ - ▏▏▏▉▏ ▎ - ▎▏▌▏▏ ▎ - ▉▏▏▋▏▍ ▌ - ▏▋▏▏▏ ▎▏ - ▏▏▏▏▎▏▎▏ - ▏▌▏▉▋█▏█ - ▏▏▉ ▋▊ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_3.txt b/codex-rs/tui2/frames/vbars/frame_3.txt deleted file mode 100644 index 6c202bc0c38..00000000000 --- a/codex-rs/tui2/frames/vbars/frame_3.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▎▋▌▌▉▉▌▌▌▌▊▎ - ▎▉▊▋▍▏▏▉▏█▌▌▏▌▎▍▏▉▉▎ - ▊▌▋▉▌▏▉██▎ ▎█▏ ▉▉▉▋▍▏▊ - ▋▉▋▍▏▌▊▉▎ ▎▌▉ ▏▏▍ - █▋▉▏▋▍▋▏▏▉▍▎ ▍▍▍▏▏▎ - ▋▏▍▍▋ ▋▊▏▍▌▍▍ ▏ ▍▍▏ - ▌▉ ▏ █▉▍▏▎█▏▊ ▋▉▋▏▊ - ▊▊▏▊▏ ▊▊▏█▋▍▏ ▏▎▏▌▏ - ▊██▏ ▌▏▉▉▏▏▉▊▏▌▉▉▎▎▎▉▉▎▏▍█▌▏ - ▊▍▋▍▊ ▋▋▉▋▌▌▏ ▏▉▏▎▎▊ ▌▋▊▌▍▎▏▍▏▎ - █▍▏ ▏ █▏▏▉▊█ █▉▉▉███▉▉▊▋▋▋▊▋ - █▉▏ ▍▉ ▎ ▋▍▉▍▋▋ - ▌▍▋▋▉▍▊ ▊▉▌▋█▊▏█ - ▌▍▉▊▎▉▉▍▌▌▌▌▊▋▌▉▌▎▎▉█ - ▎▉▉▉▎▋▏▎▎▎▎▏▌▋▍█ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_30.txt b/codex-rs/tui2/frames/vbars/frame_30.txt deleted file mode 100644 index a44dbb6ed04..00000000000 --- a/codex-rs/tui2/frames/vbars/frame_30.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▎▌█▉▉▉▊ - ▎▋▌▏▏▍▉█▌▍ - ██▍▏▊▍▍▏▉▊▍ - ▏▏▍▏▋▏▎▍▏▍ ▍▊ - ▌▉▏▏▎▍▏▊▏▊ ▏ - ▏▉▏▍▉ ▉▏▏▏▏▋▏ - ▍▋▏▏▏▍▎ ▍▋▍▏ ▌▎ - █▏ █▉▌▏▊▏█▏▊ ▎ - █▏▎█▍▏▌▏▍▋▏▊ ▎ - █▏▏▍▏▎▎▏▏ ▉▊ ▎ - ▍▏▏▌▍▎▉▏▏▏▉▏▏ - ▏▉▋▋▊▋▉▍▏▏█ ▏ - ▍▏▍▏▏▋▉▏▏▊▏▋ - ▊▍▊▉▌▍▋▏▊▋█ - ▍▊▋▏▍▎▎▌█ - ▎▎▎▎ \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_31.txt b/codex-rs/tui2/frames/vbars/frame_31.txt deleted file mode 100644 index 70da8799e29..00000000000 --- a/codex-rs/tui2/frames/vbars/frame_31.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▎▋▋▉▉▉▉▌▊ - ▎▉▉▌▉▊▎█▉▍█▉▊ - ▊▏▍▋▋▊▌▎▎▋█▏▎▉▍ - ▊▏▋▉▎▎▉▌▎▊▍▍▏▍▏▏▍ - ▍▏▏▏▏▉▊▉▍▎▊█▍▏▋▎▎ - ▌▏▍▎▏▎▏██▍▌▉▊▋▋▏▊█▏ - ▋▏▏ ▏▍▉▏▍▋▌▋▌▉▏▉▏▋▏ - ▍▍▊ ▏▏█▏▍ ▏▍██▏▏▍▋▏ - ▏▍▊▎▏█▍▏▏▏▋▉▏▏▏▎ ▏ - █▏▏▏▋▋▏▏▎▎▎▍▎▏▍▏▏ ▉ - ▍▉▎▋▉▏▊ ▋▉▉▉▎▋▏█▏▎ - ▏▏▏▍▍▍▊ ▎▌▉ ▏ - ▌▏▉▌▎ ▊ ▋ ▏▉▎▋▎ - ▋▏▍▏▋▎▏▎▊▏█▊▏▎ - ██▌▏▎▉▉▋▋▌▉ - ▎▎▎▎ \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_32.txt b/codex-rs/tui2/frames/vbars/frame_32.txt deleted file mode 100644 index ddfb4be3fe2..00000000000 --- a/codex-rs/tui2/frames/vbars/frame_32.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▊▉▉▉▉▌▌▏▊▎ - ▌▉▏▉▉▏▍▏▏▉▉▏▏▊ - ▋▍▉▌▍▊▉▍▋▊▎▉▏▉▍█▉▊ - █▋▋▏▎▊▎▌▊ ▊ ▊▎▍▏▉▊ ▊ - ▋▋▏▊▏▉▍█▉▏▌ █▎▊▏▍▋▍▉▍▊ - ▍▋▍▍▊▍▉▏▎▋▏▏▉▎ ▏▉▋ ▉ - ▉▏█▌▊▎█▍▏▏▊▊▏▊▌▎▋▎▋▋▏█▎ - ▏▏▏▊▎ █▎▏▏█▋█▏▌▊▎█▍▏██▎ - ▍▏▏▊▎ █▎▏█▎▎▏▉▉▉▏▏▏▌▏█▌▎ - ███▎▎▏▉▍█▍▎▎▎▎▎▏▏▉▏▌▏▊▎ - ▊▍▉▏▏▋ ▏▉██▉▉▉▍▌▋▋▋▋▋ - ▍▊▏▍▍▍ ▊▍▏▋█▏ - █▉▉▏▏▏▉▎ ▎▋▎▏▋ ▊ - ▍▊▍█▏▉▏▌▌▉▎▎▉█▎▋ - ▏▌▌▎▎▊▉▋▉▎██ - ▎▎▎ \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_33.txt b/codex-rs/tui2/frames/vbars/frame_33.txt deleted file mode 100644 index 7fa5ac29bca..00000000000 --- a/codex-rs/tui2/frames/vbars/frame_33.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▎▊▉▌▉▌▉▌▏▋▎ - ▎▉▉▏▏▍▉▌▏▋▏▊▏▉▏▉▋ - ▎▋▍▏▉▉▉▊▌█▎ █▉▋▏▏▉█▊ - ▊▍▋▏▋▎▏▊▎ ▉▏▏▍▎▉ - ▎▋▏▋▎▏▏▋▍▍▍ ▏▉▍▍▍ - ▋▋▊ ▍▎▍▍▎▍▊ ▋▍▋▎▍ - ▏▏▉ ▍▊▉▏▎▏▍ ▏▉▏▏ - ▉▏▊▊ ▊██▍▋▌▊ ▍█▏▉ - ▏▏▏ ▏ ▊▎ ▋▉▊▊▊▌▌▌▉▏▏▉▋▎▏▍▏ - ▍▋▏██▋▋▊▋▎▏▏▎▎▎▎▎▏▏▉▍▍▍▏▏▋ - ▍▌▍▊ ▏▋▏▋ ▋ ████▉▉▉ ▏▏▊▋▍▎ - ▍▉▍▉▊█▊ ▋▋▌▋▌▋ - ▉▍█▉▊▉▌▊ ▎▉▏▊▋▉▍█ - ▍▏█▉▍▉▏▊▌▉▎█▌▊▍▉▏▉ - █▌▌▉▊▎▎▎▍▉▉▌▊█ - ▎▎▎▎ \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_34.txt b/codex-rs/tui2/frames/vbars/frame_34.txt deleted file mode 100644 index a8c447ff18a..00000000000 --- a/codex-rs/tui2/frames/vbars/frame_34.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▎▊▌▉▉▉▉▌▏▊▎ - ▎▉▉▉▋▍▌▏█▏▉▉▌▉▉▌▊▌▎ - ▎▋▋▊▏▉█▋▏▉▎ ▎▍▍▍▊█▉▉▊ - ▊▋▎▏▉▎▋▊▎ ▉▏▊▉▍▍ - ▋▋▏▍▊▋▏ ▏█ ▊ ▍▍▉▍▍ - ▊▋▍▏▎▋ ▍▍█▏▍▉▍ ▍▊▍▏▍ - ▏▉▍▏▎ █▍▎▉▏▎▍ ▏▌▏▎ - ▍▏▌█▋ ▏▉▎▏▋█▏ ▋ ▎▍ - ▊█▋▋█ ▋▍▋▋█▎▌▌▉▉▉▉▉▉▏▎▎█ ▏▍▎ - ▏▏▍▊▋▊▊▍█▋▋█▏▏▏▎▏▋▎▎▎▊▋▎▏▏▎▏▍ - ▋▎▍▊ ▏▍▏▉▍ █▉▉▎▎▎▎▍ ▊▋▊▍▏█ - ▋▊▍▌▌▍▎ ▋▌▋▏▋█ - ▍▉▎▏▉▍▌▊ ▎▉▌█▎▋▊▎ - ▊▍▋▏█▏▉▊▌▊▉▌▉▌▉▎▉▉▋█ - █▏▍▌▌▎▎▎▎▎▌▌▉▌▍ - ▎▎▎ \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_35.txt b/codex-rs/tui2/frames/vbars/frame_35.txt deleted file mode 100644 index ba905231e1f..00000000000 --- a/codex-rs/tui2/frames/vbars/frame_35.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▎▊▉▌▌▉▉▉▌▉▊▎ - ▊▉▌▉▋▏▉▉▉█▏▉▏▋█▏▉▌▎ - ▎▋▋▏▌▉▉▏▍▉█▎ ▎█▉▌▉▋█▏▉▊ - ▊▋▋▉▋▊▌▊▎ ▉▉▎▍▏▎ - ▋▋▏▏▋▋▍ █▍▉▊ ▏▍▉▏▊ - ▌▋▌▋▉▎ █▏▎█▋▋▉▎ ▏▍▍▏ - ▍ ▋▋ ▍▍ █▉▎▋ ▏▍▍▍ - ▏▏▏▋ ▋▍▍▌▋█ ▋ █▋ - ▋ ▊▎ ▊▏▏▋▌█▋▏▌▌▎▌▎▌▉▏▏ ▏▏▌▉ - ▏▊▍▌█ ▋▋▌█▉▉▋▏▌ ▍▊▎▎▎▎▋█▏▋█ ▏█ - ▋▎▍▊█▋▍▎▏▋▍▎ ▎█▍▍▍▍▉▍▍▉▋▋▎▊▏ - █▋▍▍▋▏ ▎▋▉ ▊▍ - ▍▌▎▍▌▊▋▊ ▊▌▋▎▉▉█ - █▍▎█▏▉▏▊▋▏▏▉▏▌▋▉█▎▋▌█ - █▍▏▌▌▎▎▋▎▎▉▉▉█▍ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_36.txt b/codex-rs/tui2/frames/vbars/frame_36.txt deleted file mode 100644 index 246ed3d6924..00000000000 --- a/codex-rs/tui2/frames/vbars/frame_36.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▎▎▉▌▉▉▉▉▌▉▊▎ - ▎▌██▍▉▋▌▋▉▍▉▌▉▉█▉▉▉▎ - ▊▍█▍▊▉▉▊▉█▎▎ ▎█▉▏▉▉▏▊▉▏▊ - ▎▏█▉▉▎▎▎▎ █▉▏▍█▏ - ▊▋▎▌▍█▏▍▎▍▊▋ ▍▍▋▉ - ▋ ▊▋▎ ▉▎ █▏▋▊ █▍▍▉ - ▏ ▌ ▎ ▍ █▍▍ ▏▍▏▊ - ▏▏▍▏ ▎▍ ▎▋▋ ▍▏ ▏ - ▏██▏ ▋▋ ▊▉██▊▉▉▎▉▉▉▉▉▎ ▏▍▏▏ - ▎▉▏▍ ▊▏▎▎▋▏▋ ▎▏▎▎▎▎▎▎▎▏▏ ▍▋█▎ - █ ▉▏▍ ▉▎▉▋▍ █▉▉▍▍▍▍▍█ ▏▊█▋ - █▊█▍▌▋ ▊▋▏█▋ - ▍▋▏▏▉▏▊ ▊▉▉█▋▊▎ - ▉▊▎█▉▉▌▍▌▏▎▉▏▌█▊█▊▌▉█ - ▎▉▉▋▏▎▊▊▎▊▊▉▉▉▉█ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_4.txt b/codex-rs/tui2/frames/vbars/frame_4.txt deleted file mode 100644 index 5dcae750bc0..00000000000 --- a/codex-rs/tui2/frames/vbars/frame_4.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▎▋▌▌▉▌▌▉▌▉▊▎ - ▎▊█▉▉▏▋ ▏▍▌▋▉▌▏█▋▉▊▎ - ▊▉▌▏▏▏▉▉█▎ ▎▍▏▍▍▉▏▋▍▍▎ - ▋▋▏▊▏█▋▉▊ █▏▉▉▍▉▍ - ▋▏▋▎▏▌▏▍▌▍▏▊ ▍█▍▏▉▍ - ▋▏▏▍▏ █▎▏▏▋▉▍ █ ▍▌▏▊ - █▍▏▌▎ █▉▉▍▉█▏▊ ▊ █▌▏ - ▏▋▍ ▊▏▋▉▍▏▏ ▏▏▏ - ▎▏ ▏▎ ▌▏▋█▍▋▋▉▏▉▉▉▎▎▎▎▊▊ ▏▏▉ - ▏▍▋ ▏ ▊▋▋▋▊▎▏▎▌▍▏▊▋▎▊▋▋▍▌▏▋▋█▏ - ▎▏▌█▍▊▍▍▊▍▋ █▉▉▉▉▉▉▉█▍▊▏▏▏▎ - ▊▏▍█▏▎▎ ▊▍▌▋▉▉█ - ▍▍▊▋▍ ▊▎ ▎▋▍▊▉▌▏▋ - ▏▏▏▌▎▉▉▍▌▌▌▌▊▉▌▉▉▍▏▉▎ - ▉▍▍▏▋▏▎▎▎▎▎▌▊▉▎ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_5.txt b/codex-rs/tui2/frames/vbars/frame_5.txt deleted file mode 100644 index cab16091cb9..00000000000 --- a/codex-rs/tui2/frames/vbars/frame_5.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▎▋▌▌▌▉▌▌▌▉▊▎ - ▎▉█▉▋▉▋▏▏▉▏▌▉▌▎▏█▉▊ - ▉▎▋▍▉▏▉█▎ █▍▌▏▉▍ ▊▉▊ - ▊▏▋▏▋▏▋▌▊ ▊▍▏▏▏▏▎ - ▊▍▋▉▋▍▎▉▏▎▍▊ ▎ ▍▏▍▏▎ - ▏▏▏▏▋ ▍▎▏▍▌▋▏▎ ▉▋▏▏ - ▏▋▏ ▉ ▎▍▎▏▍█▏▊ ██▏▉▏▊ - ▏▋▏▎█ ▊▎▌▏▋▎▏ ▉▏▎▏ - ▏▊▏▍▉ ▋▎▏█▊▏▉▌▎▏▉▏▎▎▎▉▊▎█▏▋▏ - ▎▍▏▍▊▊▋▉▉█ ▏█ ▋▋▍▌▍▊▌▋▊▋▏▋▉▏▎ - ▍▊▏▏▊▏▊▍▍▋▉▎ █▉▉█████▊▏▌▏▍▋ - ▍▍▉▋▍▍ ▎ ▋▎▋▉▊▍ - ▍▋▉▍▏▌▉▎ ▎▌▉▊▉█▌▉ - ▍▏▉▏▊▉▉▍▌▏▌▎▊▉▌▉▍▏▌▉ - ▍▊▉▎▉█▎▎▎▊▎▊▉▉▎ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_6.txt b/codex-rs/tui2/frames/vbars/frame_6.txt deleted file mode 100644 index e41e013ab0f..00000000000 --- a/codex-rs/tui2/frames/vbars/frame_6.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▎▋▌▌▌▉▌▌▉▉▎▎ - ▊▍▌▋▉▏▌▉▉▏▉▉▌▎▊▉▉▎ - ▊▊▊▏▏▏▉█ ▎█▏▋▌▉▉▉▋▍▎ - ▋▊▏▉▏▉▎▌▊ ▍▉▏▏▏▏▊ - █▌▏▏▏▉▍▍▉▉▍ ▋▍▍▏▍▊ - ▌▊▋▏▋▎▍▉▌▏▍▎▏▊ ▋▌▍▋▏ - ▎ ▉▏▏ ▍▍▏▋▍▍ ▍▉▏▎▏ - ▏ ▏▏▏ ▎▏▌▏▋▏▏ ▏▉▍▏▏ - ▎▋▏▊▏ ▋█▏▏▉▌▋▉▎▏▉▏▎▎▉▉▎▋▍▋▏ - ▏▏▍▊▋▎▍▎▌▉ ▏▋▌▉▊▏▏▊▌▌▌▍▏▊▏▍ - ▋ ▏ ▋▏▏▏▌▋█ █▉████▉█▎▍▍▋▏ - ▊ ▌ ▋▊▎ ▊▋▎▋▋▏▎ - ▋▎▍▎▉▉▊ ▎▉▍▎▋▉▊▉ - ▎█▏▉▊█▉ ▌▏▎▎▊▉▉▉ ▋▌█ - ▍▊▌▉▉▊▎▎▎▎▎▊▉▉ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_7.txt b/codex-rs/tui2/frames/vbars/frame_7.txt deleted file mode 100644 index 7a88d5ef148..00000000000 --- a/codex-rs/tui2/frames/vbars/frame_7.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▎▋▏▌▉▉▌▌▉▊▎ - ▊▉▎▉▍▏▉▉▉▌▌▍▌ ▎▉▊ - ▉ ▊▏▏▏▉▎ ▎▍▏▎ ▉▋▎▉▊ - ▍▍▏▏▏▏▌▉ ▍▏▍▉▏▋▊ - ▎█▏▉▏▏▍▏▋▍▊ ▋ ▉▏▏▍ - ▏ ▌▏▏█ ▎█▍▌▏▍ ▊▊▏▏▏ - ▎▍█▏ ▍▎▏▍▏▍▏ ▉▉▍▏▏ - ▎█▋▏▏ ▌▉▋▏▉▏▎ █ ▏▏▏ - ▋█▏▉ ▊█▎▋▏▍▋▏▏▌▌▎▏▏▌▌▉▍▏▏ - ▏▊█▏▏▌▋▏▏▋█▏▋▍▏▏▍▍▊▌▊▌▍▏▍▏ - ▍ ▌▍▋▉▏▉▎▋ ▉▉▉▉▉▉██▎▋▏▏ - █▎ ▋▎▋▎▎ ▊█▊▋▌▋█ - █▌▊▉▍▍▍▎ ▊▍▏▊▉▋▉█ - ▍▎█▍▏▉▍▌▏▎▎▎▉▉▏▉▉▋ - █▏▉█▏▋▏▎▎▊▎▌▉▉ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_8.txt b/codex-rs/tui2/frames/vbars/frame_8.txt deleted file mode 100644 index bbf2016faba..00000000000 --- a/codex-rs/tui2/frames/vbars/frame_8.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▎▎▉▎▌▉▉▌▉▊▎ - ▊█▉▋▏▏▏▉▌▌ ▏ █▉ - ▎▏▌▊▏▋▋▉ ▊█▏▋▏▉▍▉▍▊ - ▎▉▍▉▋▏▊▉▏ █ ▌▍▏▏▍ - █ ▋▏▋▊▍▏▋▍ ▋█▍▏▏▊ - ▏█▊▏▍▍▍▎▍▏▎▉ ▍▉▏▏▏ - ▏▉▏▏▏ █▎▌▍▏▎▏ ▏▍▏▋▏▊ - ▍▊▏▏▋ ▏▉▍▋▏▊▉▋ ▋▎▋▏▏ - ▍ ▍▏▏▎▍ ▋▉▌▏▏▋▉▊▉▊▊▍▏▏▏ - ▍▉▋▌▎▏█▊▏▊▏▏▌▏▌▎▎▏▏▌█ - ▋▋▍▍▏▍▏▏▏▋█▍▉▍▉█▍▉█▋▍▏ - █▊█▏▍▏▎▎ ▋▊▋▋▋█ - █▍▎▉▍▍▍▎ ▎▋▍▎▏▉▋▉ - ▋▋█▏▊▉ ▌▌▉▉▌▋▌▊▏▎ - ▏▏▎▉▉▍▏▎▊▉▋▌▎ - ▎▎ \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_9.txt b/codex-rs/tui2/frames/vbars/frame_9.txt deleted file mode 100644 index 4e36e6e126f..00000000000 --- a/codex-rs/tui2/frames/vbars/frame_9.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▋▌▍▉▋▉▉▌▊ - ▋▉▎▋▏▏▉█▌ ▎▊▍▎ - ▋ ▊▏▏▋▉▍▍▌▋ ▍▎▏▊ - █ ▏▏▋▌▌▎ ▎▍▊▏▍▍▏▉▊ - ▋▉█▌▍▉█▏▉▊ ▉▋█▉▋▉▏▏ - ▏ ▏▏▏█▍▏▍▌▎▎ ▏█▏▉▏▏▏ - ▎ ▏▉▍▏▉▉▏▍▍▊█▋▊▋ ▎▏▏ - ▏ ▏▋▎▊▍▏▏▏▎▋▌▍▎▏ ▏▏ - █▊▏█ ▋▏█▏▏▏▏▉▏▏▊▏▏▎ - ▏▎▏▏▏▎▊▏▍▏▎▏▏▏▏▎▎▏▏▏ - █ ▎▍▋▍▍▏▋█▉▉▉▉▏▏█▋▍▏ - ▍▏▏▏▍▊▎ ▊▋ ▍▋▏▎ - ▍▉▍▏▍▍ ▋▌▎▌▏▋▉ - ▍▊█▍▎▏▉▋▉▌▌▌▉▏▎ - █▊▎ ▉▍▍▏▌▉▋█ - \ No newline at end of file diff --git a/codex-rs/tui2/prompt_for_init_command.md b/codex-rs/tui2/prompt_for_init_command.md deleted file mode 100644 index b8fd3886b3e..00000000000 --- a/codex-rs/tui2/prompt_for_init_command.md +++ /dev/null @@ -1,40 +0,0 @@ -Generate a file named AGENTS.md that serves as a contributor guide for this repository. -Your goal is to produce a clear, concise, and well-structured document with descriptive headings and actionable explanations for each section. -Follow the outline below, but adapt as needed — add sections if relevant, and omit those that do not apply to this project. - -Document Requirements - -- Title the document "Repository Guidelines". -- Use Markdown headings (#, ##, etc.) for structure. -- Keep the document concise. 200-400 words is optimal. -- Keep explanations short, direct, and specific to this repository. -- Provide examples where helpful (commands, directory paths, naming patterns). -- Maintain a professional, instructional tone. - -Recommended Sections - -Project Structure & Module Organization - -- Outline the project structure, including where the source code, tests, and assets are located. - -Build, Test, and Development Commands - -- List key commands for building, testing, and running locally (e.g., npm test, make build). -- Briefly explain what each command does. - -Coding Style & Naming Conventions - -- Specify indentation rules, language-specific style preferences, and naming patterns. -- Include any formatting or linting tools used. - -Testing Guidelines - -- Identify testing frameworks and coverage requirements. -- State test naming conventions and how to run tests. - -Commit & Pull Request Guidelines - -- Summarize commit message conventions found in the project’s Git history. -- Outline pull request requirements (descriptions, linked issues, screenshots, etc.). - -(Optional) Add other sections if relevant, such as Security & Configuration Tips, Architecture Overview, or Agent-Specific Instructions. diff --git a/codex-rs/tui2/src/additional_dirs.rs b/codex-rs/tui2/src/additional_dirs.rs deleted file mode 100644 index 54746c17052..00000000000 --- a/codex-rs/tui2/src/additional_dirs.rs +++ /dev/null @@ -1,83 +0,0 @@ -use codex_core::protocol::SandboxPolicy; -use std::path::PathBuf; - -/// Returns a warning describing why `--add-dir` entries will be ignored for the -/// resolved sandbox policy. The caller is responsible for presenting the -/// warning to the user (for example, printing to stderr). -pub fn add_dir_warning_message( - additional_dirs: &[PathBuf], - sandbox_policy: &SandboxPolicy, -) -> Option { - if additional_dirs.is_empty() { - return None; - } - - match sandbox_policy { - SandboxPolicy::WorkspaceWrite { .. } - | SandboxPolicy::DangerFullAccess - | SandboxPolicy::ExternalSandbox { .. } => None, - SandboxPolicy::ReadOnly => Some(format_warning(additional_dirs)), - } -} - -fn format_warning(additional_dirs: &[PathBuf]) -> String { - let joined_paths = additional_dirs - .iter() - .map(|path| path.to_string_lossy()) - .collect::>() - .join(", "); - format!( - "Ignoring --add-dir ({joined_paths}) because the effective sandbox mode is read-only. Switch to workspace-write or danger-full-access to allow additional writable roots." - ) -} - -#[cfg(test)] -mod tests { - use super::add_dir_warning_message; - use codex_core::protocol::NetworkAccess; - use codex_core::protocol::SandboxPolicy; - use pretty_assertions::assert_eq; - use std::path::PathBuf; - - #[test] - fn returns_none_for_workspace_write() { - let sandbox = SandboxPolicy::new_workspace_write_policy(); - let dirs = vec![PathBuf::from("/tmp/example")]; - assert_eq!(add_dir_warning_message(&dirs, &sandbox), None); - } - - #[test] - fn returns_none_for_danger_full_access() { - let sandbox = SandboxPolicy::DangerFullAccess; - let dirs = vec![PathBuf::from("/tmp/example")]; - assert_eq!(add_dir_warning_message(&dirs, &sandbox), None); - } - - #[test] - fn returns_none_for_external_sandbox() { - let sandbox = SandboxPolicy::ExternalSandbox { - network_access: NetworkAccess::Enabled, - }; - let dirs = vec![PathBuf::from("/tmp/example")]; - assert_eq!(add_dir_warning_message(&dirs, &sandbox), None); - } - - #[test] - fn warns_for_read_only() { - let sandbox = SandboxPolicy::ReadOnly; - let dirs = vec![PathBuf::from("relative"), PathBuf::from("/abs")]; - let message = add_dir_warning_message(&dirs, &sandbox) - .expect("expected warning for read-only sandbox"); - assert_eq!( - message, - "Ignoring --add-dir (relative, /abs) because the effective sandbox mode is read-only. Switch to workspace-write or danger-full-access to allow additional writable roots." - ); - } - - #[test] - fn returns_none_when_no_additional_dirs() { - let sandbox = SandboxPolicy::ReadOnly; - let dirs: Vec = Vec::new(); - assert_eq!(add_dir_warning_message(&dirs, &sandbox), None); - } -} diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs deleted file mode 100644 index b7e323b738c..00000000000 --- a/codex-rs/tui2/src/app.rs +++ /dev/null @@ -1,3202 +0,0 @@ -use crate::app_backtrack::BacktrackState; -use crate::app_event::AppEvent; -use crate::app_event::ExitMode; -#[cfg(target_os = "windows")] -use crate::app_event::WindowsSandboxEnableMode; -#[cfg(target_os = "windows")] -use crate::app_event::WindowsSandboxFallbackReason; -use crate::app_event_sender::AppEventSender; -use crate::bottom_pane::ApprovalRequest; -use crate::chatwidget::ChatWidget; -use crate::custom_terminal::Frame; -use crate::diff_render::DiffSummary; -use crate::exec_command::strip_bash_lc_and_escape; -use crate::file_search::FileSearchManager; -use crate::history_cell::HistoryCell; -use crate::history_cell::UserHistoryCell; -use crate::model_migration::ModelMigrationOutcome; -use crate::model_migration::migration_copy_for_models; -use crate::model_migration::run_model_migration_prompt; -use crate::pager_overlay::Overlay; -use crate::render::highlight::highlight_bash_to_lines; -use crate::render::renderable::Renderable; -use crate::resume_picker::SessionSelection; -use crate::transcript_copy_action::TranscriptCopyAction; -use crate::transcript_copy_action::TranscriptCopyFeedback; -use crate::transcript_copy_ui::TranscriptCopyUi; -use crate::transcript_multi_click::TranscriptMultiClick; -use crate::transcript_scrollbar::render_transcript_scrollbar_if_active; -use crate::transcript_scrollbar::split_transcript_area; -use crate::transcript_scrollbar_ui::TranscriptScrollbarMouseEvent; -use crate::transcript_scrollbar_ui::TranscriptScrollbarMouseHandling; -use crate::transcript_scrollbar_ui::TranscriptScrollbarUi; -use crate::transcript_selection::TRANSCRIPT_GUTTER_COLS; -use crate::transcript_selection::TranscriptSelection; -use crate::transcript_selection::TranscriptSelectionPoint; -use crate::transcript_view_cache::TranscriptViewCache; -use crate::tui; -use crate::tui::TuiEvent; -use crate::tui::scrolling::MouseScrollState; -use crate::tui::scrolling::ScrollConfig; -use crate::tui::scrolling::ScrollConfigOverrides; -use crate::tui::scrolling::ScrollDirection; -use crate::tui::scrolling::ScrollUpdate; -use crate::tui::scrolling::TranscriptScroll; -use crate::update_action::UpdateAction; -use codex_ansi_escape::ansi_escape_line; -use codex_core::AuthManager; -use codex_core::CodexAuth; -use codex_core::ThreadManager; -use codex_core::config::Config; -use codex_core::config::edit::ConfigEdit; -use codex_core::config::edit::ConfigEditsBuilder; -#[cfg(target_os = "windows")] -use codex_core::features::Feature; -use codex_core::models_manager::manager::RefreshStrategy; -use codex_core::models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; -use codex_core::models_manager::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; -use codex_core::protocol::DeprecationNoticeEvent; -use codex_core::protocol::EventMsg; -use codex_core::protocol::FinalOutput; -use codex_core::protocol::ListSkillsResponseEvent; -use codex_core::protocol::Op; -use codex_core::protocol::SessionSource; -use codex_core::protocol::SkillErrorInfo; -use codex_core::protocol::TokenUsage; -use codex_core::terminal::terminal_info; -use codex_otel::OtelManager; -use codex_protocol::ThreadId; -use codex_protocol::openai_models::ModelPreset; -use codex_protocol::openai_models::ModelUpgrade; -use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; -use color_eyre::eyre::Result; -use color_eyre::eyre::WrapErr; -use crossterm::event::KeyCode; -use crossterm::event::KeyEvent; -use crossterm::event::KeyEventKind; -use crossterm::event::MouseButton; -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -use ratatui::style::Stylize; -use ratatui::text::Line; -use ratatui::widgets::Clear; -use ratatui::widgets::Paragraph; -use ratatui::widgets::WidgetRef; -use ratatui::widgets::Wrap; -use std::collections::BTreeMap; -use std::path::Path; -use std::path::PathBuf; -use std::sync::Arc; -use std::sync::atomic::AtomicBool; -use std::sync::atomic::Ordering; -use std::thread; -use std::time::Duration; -use std::time::Instant; -use tokio::select; -use tokio::sync::mpsc::unbounded_channel; - -#[cfg(not(debug_assertions))] -use crate::history_cell::UpdateAvailableHistoryCell; - -#[derive(Debug, Clone)] -pub struct AppExitInfo { - pub token_usage: TokenUsage, - pub conversation_id: Option, - pub update_action: Option, - pub exit_reason: ExitReason, - /// ANSI-styled transcript lines to print after the TUI exits. - /// - /// These lines are rendered against the same width as the final TUI - /// viewport and include styling (colors, bold, etc.) so that scrollback - /// preserves the visual structure of the on-screen transcript. - pub session_lines: Vec, -} - -#[derive(Debug)] -pub(crate) enum AppRunControl { - Continue, - Exit(ExitReason), -} - -#[derive(Debug, Clone)] -pub enum ExitReason { - UserRequested, - Fatal(String), -} - -impl From for codex_tui::AppExitInfo { - fn from(info: AppExitInfo) -> Self { - let exit_reason = match info.exit_reason { - ExitReason::UserRequested => codex_tui::ExitReason::UserRequested, - ExitReason::Fatal(message) => codex_tui::ExitReason::Fatal(message), - }; - codex_tui::AppExitInfo { - token_usage: info.token_usage, - thread_id: info.conversation_id, - update_action: info.update_action.map(Into::into), - exit_reason, - } - } -} - -fn session_summary( - token_usage: TokenUsage, - conversation_id: Option, -) -> Option { - if token_usage.is_zero() { - return None; - } - - let usage_line = FinalOutput::from(token_usage).to_string(); - let resume_command = - conversation_id.map(|conversation_id| format!("codex resume {conversation_id}")); - Some(SessionSummary { - usage_line, - resume_command, - }) -} - -fn errors_for_cwd(cwd: &Path, response: &ListSkillsResponseEvent) -> Vec { - response - .skills - .iter() - .find(|entry| entry.cwd.as_path() == cwd) - .map(|entry| entry.errors.clone()) - .unwrap_or_default() -} - -fn emit_skill_load_warnings(app_event_tx: &AppEventSender, errors: &[SkillErrorInfo]) { - if errors.is_empty() { - return; - } - - let error_count = errors.len(); - app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( - crate::history_cell::new_warning_event(format!( - "Skipped loading {error_count} skill(s) due to invalid SKILL.md files." - )), - ))); - - for error in errors { - let path = error.path.display(); - let message = error.message.as_str(); - app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( - crate::history_cell::new_warning_event(format!("{path}: {message}")), - ))); - } -} - -fn emit_deprecation_notice(app_event_tx: &AppEventSender, notice: Option) { - let Some(DeprecationNoticeEvent { summary, details }) = notice else { - return; - }; - app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( - crate::history_cell::new_deprecation_notice(summary, details), - ))); -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct SessionSummary { - usage_line: String, - resume_command: Option, -} - -fn should_show_model_migration_prompt( - current_model: &str, - target_model: &str, - seen_migrations: &BTreeMap, - available_models: &[ModelPreset], -) -> bool { - if target_model == current_model { - return false; - } - - if let Some(seen_target) = seen_migrations.get(current_model) - && seen_target == target_model - { - return false; - } - - if available_models - .iter() - .any(|preset| preset.model == current_model && preset.upgrade.is_some()) - { - return true; - } - - if available_models - .iter() - .any(|preset| preset.upgrade.as_ref().map(|u| u.id.as_str()) == Some(target_model)) - { - return true; - } - - false -} - -fn migration_prompt_hidden(config: &Config, migration_config_key: &str) -> bool { - match migration_config_key { - HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG => config - .notices - .hide_gpt_5_1_codex_max_migration_prompt - .unwrap_or(false), - HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG => { - config.notices.hide_gpt5_1_migration_prompt.unwrap_or(false) - } - _ => false, - } -} - -async fn handle_model_migration_prompt_if_needed( - tui: &mut tui::Tui, - config: &mut Config, - model: &str, - app_event_tx: &AppEventSender, - available_models: Vec, -) -> Option { - let upgrade = available_models - .iter() - .find(|preset| preset.model == model) - .and_then(|preset| preset.upgrade.as_ref()); - - if let Some(ModelUpgrade { - id: target_model, - reasoning_effort_mapping, - migration_config_key, - migration_markdown, - .. - }) = upgrade - { - if migration_prompt_hidden(config, migration_config_key.as_str()) { - return None; - } - - let target_model = target_model.to_string(); - if !should_show_model_migration_prompt( - model, - &target_model, - &config.notices.model_migrations, - &available_models, - ) { - return None; - } - - let current_preset = available_models.iter().find(|preset| preset.model == model); - let target_preset = available_models - .iter() - .find(|preset| preset.model == target_model); - let target_display_name = target_preset - .map(|preset| preset.display_name.clone()) - .unwrap_or_else(|| target_model.clone()); - let heading_label = if target_display_name == model { - target_model.clone() - } else { - target_display_name.clone() - }; - let target_description = target_preset.and_then(|preset| { - if preset.description.is_empty() { - None - } else { - Some(preset.description.clone()) - } - }); - let can_opt_out = current_preset.is_some(); - let prompt_copy = migration_copy_for_models( - model, - &target_model, - heading_label, - target_description, - migration_markdown.clone(), - can_opt_out, - ); - match run_model_migration_prompt(tui, prompt_copy).await { - ModelMigrationOutcome::Accepted => { - app_event_tx.send(AppEvent::PersistModelMigrationPromptAcknowledged { - from_model: model.to_string(), - to_model: target_model.clone(), - }); - - let mapped_effort = if let Some(reasoning_effort_mapping) = reasoning_effort_mapping - && let Some(reasoning_effort) = config.model_reasoning_effort - { - reasoning_effort_mapping - .get(&reasoning_effort) - .cloned() - .or(config.model_reasoning_effort) - } else { - config.model_reasoning_effort - }; - - config.model = Some(target_model.clone()); - config.model_reasoning_effort = mapped_effort; - app_event_tx.send(AppEvent::UpdateModel(target_model.clone())); - app_event_tx.send(AppEvent::UpdateReasoningEffort(mapped_effort)); - app_event_tx.send(AppEvent::PersistModelSelection { - model: target_model.clone(), - effort: mapped_effort, - }); - } - ModelMigrationOutcome::Rejected => { - app_event_tx.send(AppEvent::PersistModelMigrationPromptAcknowledged { - from_model: model.to_string(), - to_model: target_model.clone(), - }); - } - ModelMigrationOutcome::Exit => { - return Some(AppExitInfo { - token_usage: TokenUsage::default(), - conversation_id: None, - update_action: None, - exit_reason: ExitReason::UserRequested, - session_lines: Vec::new(), - }); - } - } - } - - None -} - -pub(crate) struct App { - pub(crate) server: Arc, - pub(crate) otel_manager: OtelManager, - pub(crate) app_event_tx: AppEventSender, - pub(crate) chat_widget: ChatWidget, - pub(crate) auth_manager: Arc, - /// Config is stored here so we can recreate ChatWidgets as needed. - pub(crate) config: Config, - pub(crate) active_profile: Option, - - pub(crate) file_search: FileSearchManager, - - pub(crate) transcript_cells: Vec>, - transcript_view_cache: TranscriptViewCache, - - #[allow(dead_code)] - transcript_scroll: TranscriptScroll, - transcript_selection: TranscriptSelection, - transcript_multi_click: TranscriptMultiClick, - transcript_view_top: usize, - transcript_total_lines: usize, - transcript_copy_ui: TranscriptCopyUi, - transcript_copy_action: TranscriptCopyAction, - transcript_scrollbar_ui: TranscriptScrollbarUi, - - // Pager overlay state (Transcript or Static like Diff). - pub(crate) overlay: Option, - /// History cells received while an overlay is active. - /// - /// While in an alt-screen overlay, the normal terminal buffer is not visible. - /// Instead we queue the incoming cells here and, on overlay close, render them at the *current* - /// width and queue them in one batch via `Tui::insert_history_lines`. - /// - /// This matters for correctness if/when scrollback printing is enabled: if we deferred - /// already-rendered `Vec`, we'd bake viewport-width wrapping based on the width at the - /// time the cell arrived (which may differ from the width when the overlay closes). - pub(crate) deferred_history_cells: Vec>, - /// True once at least one history cell has been inserted into terminal scrollback. - /// - /// Used to decide whether to insert an extra blank separator line when flushing deferred cells. - pub(crate) has_emitted_history_lines: bool, - - pub(crate) enhanced_keys_supported: bool, - - /// Controls the animation thread that sends CommitTick events. - pub(crate) commit_anim_running: Arc, - - scroll_config: ScrollConfig, - scroll_state: MouseScrollState, - - // Esc-backtracking state grouped - pub(crate) backtrack: crate::app_backtrack::BacktrackState, - /// When set, the next draw re-renders the transcript into terminal scrollback once. - /// - /// This is used after a confirmed conversation rollback to ensure scrollback reflects the - /// trimmed transcript cells. - pub(crate) backtrack_render_pending: bool, - pub(crate) feedback: codex_feedback::CodexFeedback, - /// Set when the user confirms an update; propagated on exit. - pub(crate) pending_update_action: Option, - - /// Ignore the next ShutdownComplete event when we're intentionally - /// stopping a conversation (e.g., before starting a new one). - suppress_shutdown_complete: bool, - - windows_sandbox: WindowsSandboxState, -} - -#[derive(Default)] -struct WindowsSandboxState { - setup_started_at: Option, - // One-shot suppression of the next world-writable scan after user confirmation. - skip_world_writable_scan_once: bool, -} -impl App { - pub fn chatwidget_init_for_forked_or_resumed_thread( - &self, - tui: &mut tui::Tui, - cfg: codex_core::config::Config, - ) -> crate::chatwidget::ChatWidgetInit { - crate::chatwidget::ChatWidgetInit { - config: cfg, - frame_requester: tui.frame_requester(), - app_event_tx: self.app_event_tx.clone(), - // Fork/resume bootstraps here don't carry any prefilled message content. - initial_user_message: None, - enhanced_keys_supported: self.enhanced_keys_supported, - auth_manager: self.auth_manager.clone(), - models_manager: self.server.get_models_manager(), - feedback: self.feedback.clone(), - is_first_run: false, - model: Some(self.chat_widget.current_model().to_string()), - otel_manager: self.otel_manager.clone(), - } - } - - async fn shutdown_current_conversation(&mut self) { - if let Some(conversation_id) = self.chat_widget.conversation_id() { - // Clear any in-flight rollback guard when switching conversations. - self.backtrack.pending_rollback = None; - self.suppress_shutdown_complete = true; - self.chat_widget.submit_op(Op::Shutdown); - self.server.remove_thread(&conversation_id).await; - } - } - - #[allow(clippy::too_many_arguments)] - pub async fn run( - tui: &mut tui::Tui, - auth_manager: Arc, - mut config: Config, - active_profile: Option, - initial_prompt: Option, - initial_images: Vec, - session_selection: SessionSelection, - feedback: codex_feedback::CodexFeedback, - is_first_run: bool, - ollama_chat_support_notice: Option, - ) -> Result { - use tokio_stream::StreamExt; - let (app_event_tx, mut app_event_rx) = unbounded_channel(); - let app_event_tx = AppEventSender::new(app_event_tx); - emit_deprecation_notice(&app_event_tx, ollama_chat_support_notice); - - let thread_manager = Arc::new(ThreadManager::new( - config.codex_home.clone(), - auth_manager.clone(), - SessionSource::Cli, - )); - let mut model = thread_manager - .get_models_manager() - .get_default_model(&config.model, &config, RefreshStrategy::Offline) - .await; - let available_models = thread_manager - .get_models_manager() - .list_models(&config, RefreshStrategy::Offline) - .await; - let exit_info = handle_model_migration_prompt_if_needed( - tui, - &mut config, - model.as_str(), - &app_event_tx, - available_models, - ) - .await; - if let Some(exit_info) = exit_info { - return Ok(exit_info); - } - if let Some(updated_model) = config.model.clone() { - model = updated_model; - } - - let auth = auth_manager.auth().await; - let auth_ref = auth.as_ref(); - let model_info = thread_manager - .get_models_manager() - .get_model_info(model.as_str(), &config) - .await; - let otel_manager = OtelManager::new( - ThreadId::new(), - model.as_str(), - model_info.slug.as_str(), - auth_ref.and_then(CodexAuth::get_account_id), - auth_ref.and_then(CodexAuth::get_account_email), - auth_ref.map(|auth| auth.mode), - config.otel.log_user_prompt, - codex_core::terminal::user_agent(), - SessionSource::Cli, - ); - - let enhanced_keys_supported = tui.enhanced_keys_supported(); - let mut chat_widget = match session_selection { - SessionSelection::StartFresh | SessionSelection::Exit => { - let init = crate::chatwidget::ChatWidgetInit { - config: config.clone(), - frame_requester: tui.frame_requester(), - app_event_tx: app_event_tx.clone(), - initial_user_message: crate::chatwidget::create_initial_user_message( - initial_prompt.clone(), - initial_images.clone(), - // CLI prompt args are plain strings, so they don't provide element ranges. - Vec::new(), - ), - enhanced_keys_supported, - auth_manager: auth_manager.clone(), - models_manager: thread_manager.get_models_manager(), - feedback: feedback.clone(), - is_first_run, - model: Some(model.clone()), - otel_manager: otel_manager.clone(), - }; - ChatWidget::new(init, thread_manager.clone()) - } - SessionSelection::Resume(path) => { - let resumed = thread_manager - .resume_thread_from_rollout(config.clone(), path.clone(), auth_manager.clone()) - .await - .wrap_err_with(|| { - let path_display = path.display(); - format!("Failed to resume session from {path_display}") - })?; - let init = crate::chatwidget::ChatWidgetInit { - config: config.clone(), - frame_requester: tui.frame_requester(), - app_event_tx: app_event_tx.clone(), - initial_user_message: crate::chatwidget::create_initial_user_message( - initial_prompt.clone(), - initial_images.clone(), - // CLI prompt args are plain strings, so they don't provide element ranges. - Vec::new(), - ), - enhanced_keys_supported, - auth_manager: auth_manager.clone(), - models_manager: thread_manager.get_models_manager(), - feedback: feedback.clone(), - is_first_run, - model: config.model.clone(), - otel_manager: otel_manager.clone(), - }; - ChatWidget::new_from_existing(init, resumed.thread, resumed.session_configured) - } - SessionSelection::Fork(path) => { - let forked = thread_manager - .fork_thread(usize::MAX, config.clone(), path.clone()) - .await - .wrap_err_with(|| { - let path_display = path.display(); - format!("Failed to fork session from {path_display}") - })?; - let init = crate::chatwidget::ChatWidgetInit { - config: config.clone(), - frame_requester: tui.frame_requester(), - app_event_tx: app_event_tx.clone(), - initial_user_message: crate::chatwidget::create_initial_user_message( - initial_prompt.clone(), - initial_images.clone(), - // CLI prompt args are plain strings, so they don't provide element ranges. - Vec::new(), - ), - enhanced_keys_supported, - auth_manager: auth_manager.clone(), - models_manager: thread_manager.get_models_manager(), - feedback: feedback.clone(), - is_first_run, - model: config.model.clone(), - otel_manager: otel_manager.clone(), - }; - ChatWidget::new_from_existing(init, forked.thread, forked.session_configured) - } - }; - - chat_widget.maybe_prompt_windows_sandbox_enable(); - - let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); - #[cfg(not(debug_assertions))] - let upgrade_version = crate::updates::get_upgrade_version(&config); - let scroll_config = ScrollConfig::from_terminal( - &terminal_info(), - ScrollConfigOverrides { - events_per_tick: config.tui_scroll_events_per_tick, - wheel_lines_per_tick: config.tui_scroll_wheel_lines, - trackpad_lines_per_tick: config.tui_scroll_trackpad_lines, - trackpad_accel_events: config.tui_scroll_trackpad_accel_events, - trackpad_accel_max: config.tui_scroll_trackpad_accel_max, - mode: Some(config.tui_scroll_mode), - wheel_tick_detect_max_ms: config.tui_scroll_wheel_tick_detect_max_ms, - wheel_like_max_duration_ms: config.tui_scroll_wheel_like_max_duration_ms, - invert_direction: config.tui_scroll_invert, - }, - ); - - let copy_selection_shortcut = crate::transcript_copy_ui::detect_copy_selection_shortcut(); - - let mut app = Self { - server: thread_manager.clone(), - otel_manager: otel_manager.clone(), - app_event_tx, - chat_widget, - auth_manager: auth_manager.clone(), - config, - active_profile, - file_search, - enhanced_keys_supported, - transcript_cells: Vec::new(), - transcript_view_cache: TranscriptViewCache::new(), - transcript_scroll: TranscriptScroll::default(), - transcript_selection: TranscriptSelection::default(), - transcript_multi_click: TranscriptMultiClick::default(), - transcript_view_top: 0, - transcript_total_lines: 0, - transcript_copy_ui: TranscriptCopyUi::new_with_shortcut(copy_selection_shortcut), - transcript_copy_action: TranscriptCopyAction::default(), - transcript_scrollbar_ui: TranscriptScrollbarUi::default(), - overlay: None, - deferred_history_cells: Vec::new(), - has_emitted_history_lines: false, - commit_anim_running: Arc::new(AtomicBool::new(false)), - scroll_config, - scroll_state: MouseScrollState::default(), - backtrack: BacktrackState::default(), - backtrack_render_pending: false, - feedback: feedback.clone(), - pending_update_action: None, - suppress_shutdown_complete: false, - windows_sandbox: WindowsSandboxState::default(), - }; - - // On startup, if Agent mode (workspace-write) or ReadOnly is active, warn about world-writable dirs on Windows. - #[cfg(target_os = "windows")] - { - let should_check = codex_core::get_platform_sandbox().is_some() - && matches!( - app.config.sandbox_policy.get(), - codex_core::protocol::SandboxPolicy::WorkspaceWrite { .. } - | codex_core::protocol::SandboxPolicy::ReadOnly - ) - && !app - .config - .notices - .hide_world_writable_warning - .unwrap_or(false); - if should_check { - let cwd = app.config.cwd.clone(); - let env_map: std::collections::HashMap = std::env::vars().collect(); - let tx = app.app_event_tx.clone(); - let logs_base_dir = app.config.codex_home.clone(); - let sandbox_policy = app.config.sandbox_policy.get().clone(); - Self::spawn_world_writable_scan(cwd, env_map, logs_base_dir, sandbox_policy, tx); - } - } - - #[cfg(not(debug_assertions))] - if let Some(latest_version) = upgrade_version { - let control = app - .handle_event( - tui, - AppEvent::InsertHistoryCell(Box::new(UpdateAvailableHistoryCell::new( - latest_version, - crate::update_action::get_update_action(), - ))), - ) - .await?; - if let AppRunControl::Exit(exit_reason) = control { - return Ok(AppExitInfo { - token_usage: app.token_usage(), - conversation_id: app.chat_widget.conversation_id(), - update_action: app.pending_update_action, - exit_reason, - session_lines: Vec::new(), - }); - } - } - - let tui_events = tui.event_stream(); - tokio::pin!(tui_events); - - tui.frame_requester().schedule_frame(); - - let exit_reason = loop { - let control = select! { - Some(event) = app_event_rx.recv() => { - app.handle_event(tui, event).await? - } - Some(event) = tui_events.next() => { - app.handle_tui_event(tui, event).await? - } - }; - match control { - AppRunControl::Continue => {} - AppRunControl::Exit(reason) => break reason, - } - }; - let width = tui.terminal.last_known_screen_size.width; - let session_lines = if width == 0 { - Vec::new() - } else { - let transcript = - crate::transcript_render::build_transcript_lines(&app.transcript_cells, width); - let (lines, line_meta) = (transcript.lines, transcript.meta); - let is_user_cell: Vec = app - .transcript_cells - .iter() - .map(|cell| cell.as_any().is::()) - .collect(); - crate::transcript_render::render_lines_to_ansi(&lines, &line_meta, &is_user_cell, width) - }; - - tui.terminal.clear()?; - Ok(AppExitInfo { - token_usage: app.token_usage(), - conversation_id: app.chat_widget.conversation_id(), - update_action: app.pending_update_action, - exit_reason, - session_lines, - }) - } - - pub(crate) async fn handle_tui_event( - &mut self, - tui: &mut tui::Tui, - event: TuiEvent, - ) -> Result { - if matches!(&event, TuiEvent::Draw) { - self.handle_scroll_tick(tui); - } - - if self.overlay.is_some() { - let _ = self.handle_backtrack_overlay_event(tui, event).await?; - } else { - match event { - TuiEvent::Key(key_event) => { - self.handle_key_event(tui, key_event).await; - } - TuiEvent::Mouse(mouse_event) => { - self.handle_mouse_event(tui, mouse_event); - } - TuiEvent::Paste(pasted) => { - // Many terminals convert newlines to \r when pasting (e.g., iTerm2), - // but tui-textarea expects \n. Normalize CR to LF. - // [tui-textarea]: https://github.com/rhysd/tui-textarea/blob/4d18622eeac13b309e0ff6a55a46ac6706da68cf/src/textarea.rs#L782-L783 - // [iTerm2]: https://github.com/gnachman/iTerm2/blob/5d0c0d9f68523cbd0494dad5422998964a2ecd8d/sources/iTermPasteHelper.m#L206-L216 - let pasted = pasted.replace("\r", "\n"); - self.chat_widget.handle_paste(pasted); - } - TuiEvent::Draw => { - if self.backtrack_render_pending { - self.backtrack_render_pending = false; - self.render_transcript_once(tui); - } - self.chat_widget.maybe_post_pending_notification(tui); - if self - .chat_widget - .handle_paste_burst_tick(tui.frame_requester()) - { - return Ok(AppRunControl::Continue); - } - let cells = self.transcript_cells.clone(); - tui.draw(tui.terminal.size()?.height, |frame| { - let chat_height = self.chat_widget.desired_height(frame.area().width); - let chat_top = self.render_transcript_cells(frame, &cells, chat_height); - let chat_area = Rect { - x: frame.area().x, - y: chat_top, - width: frame.area().width, - height: chat_height.min( - frame - .area() - .height - .saturating_sub(chat_top.saturating_sub(frame.area().y)), - ), - }; - self.chat_widget.render(chat_area, frame.buffer); - let chat_bottom = chat_area.y.saturating_add(chat_area.height); - if chat_bottom < frame.area().bottom() { - Clear.render_ref( - Rect { - x: frame.area().x, - y: chat_bottom, - width: frame.area().width, - height: frame.area().bottom().saturating_sub(chat_bottom), - }, - frame.buffer, - ); - } - if let Some((x, y)) = self.chat_widget.cursor_pos(chat_area) { - frame.set_cursor_position((x, y)); - } - })?; - let transcript_scrolled = - !matches!(self.transcript_scroll, TranscriptScroll::ToBottom); - let selection_active = matches!( - (self.transcript_selection.anchor, self.transcript_selection.head), - (Some(a), Some(b)) if a != b - ); - let scroll_position = if self.transcript_total_lines == 0 { - None - } else { - Some(( - self.transcript_view_top.saturating_add(1), - self.transcript_total_lines, - )) - }; - let copy_selection_key = self.copy_selection_key(); - let copy_feedback = self.transcript_copy_feedback_for_footer(); - self.chat_widget.set_transcript_ui_state( - transcript_scrolled, - selection_active, - scroll_position, - copy_selection_key, - copy_feedback, - ); - } - } - } - Ok(AppRunControl::Continue) - } - - pub(crate) fn render_transcript_cells( - &mut self, - frame: &mut Frame, - cells: &[Arc], - chat_height: u16, - ) -> u16 { - let area = frame.area(); - if area.width == 0 || area.height == 0 { - self.transcript_scroll = TranscriptScroll::default(); - self.transcript_view_top = 0; - self.transcript_total_lines = 0; - return area.bottom().saturating_sub(chat_height); - } - - let chat_height = chat_height.min(area.height); - let max_transcript_height = area.height.saturating_sub(chat_height); - if max_transcript_height == 0 { - self.transcript_scroll = TranscriptScroll::default(); - self.transcript_view_top = 0; - self.transcript_total_lines = 0; - return area.y; - } - - let transcript_full_area = Rect { - x: area.x, - y: area.y, - width: area.width, - height: max_transcript_height, - }; - let (transcript_area, _) = split_transcript_area(transcript_full_area); - - self.transcript_view_cache - .ensure_wrapped(cells, transcript_area.width); - let total_lines = self.transcript_view_cache.lines().len(); - if total_lines == 0 { - Clear.render_ref(transcript_full_area, frame.buffer); - self.transcript_scroll = TranscriptScroll::default(); - self.transcript_view_top = 0; - self.transcript_total_lines = 0; - return area.y; - } - - self.transcript_total_lines = total_lines; - let max_visible = std::cmp::min(max_transcript_height as usize, total_lines); - let max_start = total_lines.saturating_sub(max_visible); - - let (scroll_state, top_offset) = { - let line_meta = self.transcript_view_cache.line_meta(); - self.transcript_scroll.resolve_top(line_meta, max_start) - }; - self.transcript_scroll = scroll_state; - self.transcript_view_top = top_offset; - - let transcript_visible_height = max_visible as u16; - let chat_top = if total_lines <= max_transcript_height as usize { - let gap = if transcript_visible_height == 0 { 0 } else { 1 }; - area.y - .saturating_add(transcript_visible_height) - .saturating_add(gap) - } else { - area.bottom().saturating_sub(chat_height) - }; - - let clear_height = chat_top.saturating_sub(area.y); - if clear_height > 0 { - Clear.render_ref( - Rect { - x: area.x, - y: area.y, - width: area.width, - height: clear_height, - }, - frame.buffer, - ); - } - - let transcript_full_area = Rect { - x: area.x, - y: area.y, - width: area.width, - height: transcript_visible_height, - }; - let (transcript_area, transcript_scrollbar_area) = - split_transcript_area(transcript_full_area); - - // Cache a few viewports worth of rasterized rows so redraws during streaming can cheaply - // copy already-rendered `Cell`s instead of re-running grapheme segmentation. - self.transcript_view_cache - .set_raster_capacity(max_visible.saturating_mul(4).max(256)); - - for (row_index, line_index) in (top_offset..total_lines).enumerate() { - if row_index >= max_visible { - break; - } - - let y = transcript_area.y + row_index as u16; - let row_area = Rect { - x: transcript_area.x, - y, - width: transcript_area.width, - height: 1, - }; - - self.transcript_view_cache - .render_row_index_into(line_index, row_area, frame.buffer); - } - - self.apply_transcript_selection(transcript_area, frame.buffer); - if let (Some(anchor), Some(head)) = ( - self.transcript_selection.anchor, - self.transcript_selection.head, - ) && anchor != head - { - self.transcript_copy_ui.render_copy_pill( - transcript_area, - frame.buffer, - (anchor.line_index, anchor.column), - (head.line_index, head.column), - self.transcript_view_top, - self.transcript_total_lines, - ); - } else { - self.transcript_copy_ui.clear_affordance(); - } - render_transcript_scrollbar_if_active( - frame.buffer, - transcript_scrollbar_area, - total_lines, - max_visible, - top_offset, - ); - chat_top - } - - /// Handle mouse interaction in the main transcript view. - /// - /// - Mouse wheel movement scrolls the conversation history using stream-based - /// normalization (events-per-line factor, discrete vs. continuous streams), - /// independent of the terminal's own scrollback. - /// - Mouse drags adjust a text selection defined in terms of - /// flattened transcript lines and columns, so the selection is anchored - /// to the underlying content rather than absolute screen rows. - /// - When the user drags to extend a selection while the view is following the bottom - /// and a task is actively running (e.g., streaming a response), the scroll mode is - /// first converted into an anchored position so that ongoing updates no longer move - /// the viewport under the selection. A simple click without a drag does not change - /// scroll behavior. - /// - Mouse events outside the transcript area (e.g. over the composer/footer) must not - /// start or mutate transcript selection state. A left-click outside the transcript - /// clears any existing transcript selection so the user can dismiss the highlight. - fn handle_mouse_event( - &mut self, - tui: &mut tui::Tui, - mouse_event: crossterm::event::MouseEvent, - ) { - use crossterm::event::MouseEventKind; - - if self.overlay.is_some() { - return; - } - - let size = tui.terminal.last_known_screen_size; - let width = size.width; - let height = size.height; - if width == 0 || height == 0 { - return; - } - - let chat_height = self.chat_widget.desired_height(width); - if chat_height >= height { - return; - } - - // Only handle events over the transcript area above the composer. - let transcript_height = height.saturating_sub(chat_height); - if transcript_height == 0 { - return; - } - - let transcript_full_area = Rect { - x: 0, - y: 0, - width, - height: transcript_height, - }; - let (transcript_area, transcript_scrollbar_area) = - split_transcript_area(transcript_full_area); - let base_x = transcript_area.x.saturating_add(TRANSCRIPT_GUTTER_COLS); - let max_x = transcript_area.right().saturating_sub(1); - - if matches!( - self.transcript_scrollbar_ui - .handle_mouse_event(TranscriptScrollbarMouseEvent { - tui, - mouse_event, - transcript_area, - scrollbar_area: transcript_scrollbar_area, - transcript_cells: &self.transcript_cells, - transcript_view_cache: &mut self.transcript_view_cache, - transcript_scroll: &mut self.transcript_scroll, - transcript_view_top: &mut self.transcript_view_top, - transcript_total_lines: &mut self.transcript_total_lines, - mouse_scroll_state: &mut self.scroll_state, - }), - TranscriptScrollbarMouseHandling::Handled - ) { - return; - } - - // Treat the transcript as the only interactive region for transcript selection. - // - // This prevents clicks in the composer/footer from starting or extending a transcript - // selection, while still allowing a left-click outside the transcript to clear an - // existing highlight. - if !self.transcript_scrollbar_ui.pointer_capture_active() - && (mouse_event.row < transcript_full_area.y - || mouse_event.row >= transcript_full_area.bottom()) - { - if matches!( - mouse_event.kind, - MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Up(MouseButton::Left) - ) && (self.transcript_selection.anchor.is_some() - || self.transcript_selection.head.is_some()) - { - self.transcript_selection = TranscriptSelection::default(); - // Mouse events do not inherently trigger a redraw; schedule one so the cleared - // highlight is reflected immediately. - tui.frame_requester().schedule_frame(); - } - return; - } - - let mut clamped_x = mouse_event.column; - let clamped_y = mouse_event.row; - if clamped_x < base_x { - clamped_x = base_x; - } - if clamped_x > max_x { - clamped_x = max_x; - } - - let streaming = self.chat_widget.is_task_running(); - - if matches!(mouse_event.kind, MouseEventKind::Down(MouseButton::Left)) - && self - .transcript_copy_ui - .hit_test(mouse_event.column, mouse_event.row) - { - if self.transcript_copy_action.copy_and_handle( - tui, - chat_height, - &self.transcript_cells, - self.transcript_selection, - ) { - self.transcript_selection = TranscriptSelection::default(); - } - return; - } - - match mouse_event.kind { - MouseEventKind::ScrollUp => { - let scroll_update = self.mouse_scroll_update(ScrollDirection::Up); - self.apply_scroll_update( - tui, - scroll_update, - transcript_area.height as usize, - transcript_area.width, - true, - ); - } - MouseEventKind::ScrollDown => { - let scroll_update = self.mouse_scroll_update(ScrollDirection::Down); - self.apply_scroll_update( - tui, - scroll_update, - transcript_area.height as usize, - transcript_area.width, - true, - ); - } - MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight => {} - MouseEventKind::Down(MouseButton::Left) => { - self.transcript_copy_ui.set_dragging(true); - let point = self.transcript_point_from_coordinates( - transcript_area, - base_x, - clamped_x, - clamped_y, - ); - if self.transcript_multi_click.on_mouse_down( - &mut self.transcript_selection, - &self.transcript_cells, - transcript_area.width, - point, - ) { - tui.frame_requester().schedule_frame(); - } - } - MouseEventKind::Drag(MouseButton::Left) => { - let point = self.transcript_point_from_coordinates( - transcript_area, - base_x, - clamped_x, - clamped_y, - ); - let outcome = crate::transcript_selection::on_mouse_drag( - &mut self.transcript_selection, - &self.transcript_scroll, - point, - streaming, - ); - self.transcript_multi_click - .on_mouse_drag(&self.transcript_selection, point); - if outcome.lock_scroll { - self.lock_transcript_scroll_to_current_view( - transcript_area.height as usize, - transcript_area.width, - ); - } - if outcome.changed { - tui.frame_requester().schedule_frame(); - } - } - MouseEventKind::Up(MouseButton::Left) => { - self.transcript_copy_ui.set_dragging(false); - let selection_changed = - crate::transcript_selection::on_mouse_up(&mut self.transcript_selection); - let has_active_selection = self.transcript_selection.anchor.is_some() - && self.transcript_selection.head.is_some(); - if selection_changed || has_active_selection { - tui.frame_requester().schedule_frame(); - } - } - _ => {} - } - } - - /// Convert a single mouse scroll event (direction-only) into a normalized scroll update. - /// - /// This delegates to [`MouseScrollState::on_scroll_event`] using the current [`ScrollConfig`]. - /// The returned [`ScrollUpdate`] is intentionally split into: - /// - /// - `lines`: a *delta* in visual lines to apply immediately to the transcript viewport. - /// - Sign convention matches [`ScrollDirection`] (`Up` is negative; `Down` is positive). - /// - May be 0 in trackpad-like mode while sub-line fractions are still accumulating. - /// - `next_tick_in`: an optional delay after which we should trigger a follow-up tick. - /// This is required because stream closure is defined by a *time gap* rather than an - /// explicit "gesture end" event. See [`App::apply_scroll_update`] and - /// [`App::handle_scroll_tick`]. - /// - /// In TUI2, that follow-up tick is driven via `TuiEvent::Draw`: we schedule a frame, and on - /// the next draw we call [`MouseScrollState::on_tick`] to close idle streams and flush any - /// newly-reached whole lines. This prevents perceived "stop lag" where accumulated scroll only - /// applies once the next user input arrives. - fn mouse_scroll_update(&mut self, direction: ScrollDirection) -> ScrollUpdate { - self.scroll_state - .on_scroll_event(direction, self.scroll_config) - } - - /// Apply a [`ScrollUpdate`] to the transcript viewport and schedule any needed follow-up tick. - /// - /// `update.lines` is applied immediately via [`App::scroll_transcript`]. - /// - /// If `update.next_tick_in` is `Some`, we schedule a future frame so `TuiEvent::Draw` can call - /// [`App::handle_scroll_tick`] and close the stream after it goes idle and/or cadence-flush - /// pending whole lines. - /// - /// `schedule_frame` is forwarded to [`App::scroll_transcript`] and controls whether scrolling - /// should request an additional draw. Pass `false` when applying scroll during a - /// `TuiEvent::Draw` tick to avoid redundant frames. - fn apply_scroll_update( - &mut self, - tui: &mut tui::Tui, - update: ScrollUpdate, - visible_lines: usize, - width: u16, - schedule_frame: bool, - ) { - if update.lines != 0 { - self.scroll_transcript(tui, update.lines, visible_lines, width, schedule_frame); - } - if let Some(delay) = update.next_tick_in { - tui.frame_requester().schedule_frame_in(delay); - } - } - - /// Drive stream closure and cadence-based flushing for mouse scrolling. - /// - /// This is called on every `TuiEvent::Draw` before rendering. If a scroll stream is active, it - /// may: - /// - /// - Close the stream once it has been idle for longer than the stream-gap threshold. - /// - Flush whole-line deltas on the redraw cadence for trackpad-like streams, even if no new - /// events arrive. - /// - /// The resulting update is applied with `schedule_frame = false` because we are already in a - /// draw tick. - fn handle_scroll_tick(&mut self, tui: &mut tui::Tui) { - let Some((visible_lines, width)) = self.transcript_scroll_dimensions(tui) else { - return; - }; - let update = self.scroll_state.on_tick(); - self.apply_scroll_update(tui, update, visible_lines, width, false); - } - - /// Compute the transcript viewport dimensions used for scrolling. - /// - /// Mouse scrolling is applied in terms of "visible transcript lines": the terminal height - /// minus the chat composer height. We compute this from the last known terminal size to avoid - /// querying the terminal during non-draw events. - /// - /// Returns `(visible_lines, width)` or `None` when the terminal is not yet sized or the chat - /// area consumes the full height. - fn transcript_scroll_dimensions(&self, tui: &tui::Tui) -> Option<(usize, u16)> { - let size = tui.terminal.last_known_screen_size; - let width = size.width; - let height = size.height; - if width == 0 || height == 0 { - return None; - } - - let chat_height = self.chat_widget.desired_height(width); - if chat_height >= height { - return None; - } - - let transcript_height = height.saturating_sub(chat_height); - if transcript_height == 0 { - return None; - } - - let transcript_full_area = Rect { - x: 0, - y: 0, - width, - height: transcript_height, - }; - let (transcript_area, _) = split_transcript_area(transcript_full_area); - - Some((transcript_height as usize, transcript_area.width)) - } - - /// Scroll the transcript by a number of visual lines. - /// - /// This is the shared implementation behind mouse wheel movement and PgUp/PgDn keys in - /// the main view. Scroll state is expressed in terms of transcript cells and their - /// internal line indices, so scrolling refers to logical conversation content and - /// remains stable even as wrapping or streaming causes visual reflows. - /// - /// `schedule_frame` controls whether to request an extra draw; pass `false` when applying - /// scroll during a `TuiEvent::Draw` tick to avoid redundant frames. - fn scroll_transcript( - &mut self, - tui: &mut tui::Tui, - delta_lines: i32, - visible_lines: usize, - width: u16, - schedule_frame: bool, - ) { - if visible_lines == 0 { - return; - } - - self.transcript_view_cache - .ensure_wrapped(&self.transcript_cells, width); - let line_meta = self.transcript_view_cache.line_meta(); - self.transcript_scroll = - self.transcript_scroll - .scrolled_by(delta_lines, line_meta, visible_lines); - - if schedule_frame { - // Request a redraw; the frame scheduler coalesces bursts and clamps to 60fps. - tui.frame_requester().schedule_frame(); - } - } - - /// Convert a `ToBottom` (auto-follow) scroll state into a fixed anchor at the current view. - /// - /// When the user begins a mouse selection while new output is streaming in, the view - /// should stop auto-following the latest line so the selection stays on the intended - /// content. This helper inspects the flattened transcript at the given width, derives - /// a concrete position corresponding to the current top row, and switches into a scroll - /// mode that keeps that position stable until the user scrolls again. - fn lock_transcript_scroll_to_current_view(&mut self, visible_lines: usize, width: u16) { - if self.transcript_cells.is_empty() || visible_lines == 0 || width == 0 { - return; - } - - self.transcript_view_cache - .ensure_wrapped(&self.transcript_cells, width); - let lines = self.transcript_view_cache.lines(); - let line_meta = self.transcript_view_cache.line_meta(); - if lines.is_empty() || line_meta.is_empty() { - return; - } - - let total_lines = lines.len(); - let max_visible = std::cmp::min(visible_lines, total_lines); - if max_visible == 0 { - return; - } - - let max_start = total_lines.saturating_sub(max_visible); - let top_offset = match self.transcript_scroll { - TranscriptScroll::ToBottom => max_start, - TranscriptScroll::Scrolled { .. } - | TranscriptScroll::ScrolledSpacerBeforeCell { .. } => { - // Already anchored; nothing to lock. - return; - } - }; - - if let Some(scroll_state) = TranscriptScroll::anchor_for(line_meta, top_offset) { - self.transcript_scroll = scroll_state; - } - } - - /// Apply the current transcript selection to the given buffer. - /// - /// The selection is defined in terms of flattened wrapped transcript line - /// indices and columns. This method maps those content-relative endpoints - /// into the currently visible viewport based on `transcript_view_top` and - /// `transcript_total_lines`, so the highlight moves with the content as the - /// user scrolls. - fn apply_transcript_selection(&self, area: Rect, buf: &mut Buffer) { - let (anchor, head) = match ( - self.transcript_selection.anchor, - self.transcript_selection.head, - ) { - (Some(a), Some(h)) => (a, h), - _ => return, - }; - - if self.transcript_total_lines == 0 { - return; - } - - let base_x = area.x.saturating_add(TRANSCRIPT_GUTTER_COLS); - let max_x = area.right().saturating_sub(1); - - let (start, end) = crate::transcript_selection::ordered_endpoints(anchor, head); - - let visible_start = self.transcript_view_top; - let visible_end = self - .transcript_view_top - .saturating_add(area.height as usize) - .min(self.transcript_total_lines); - - for (row_index, line_index) in (visible_start..visible_end).enumerate() { - if line_index < start.line_index || line_index > end.line_index { - continue; - } - - let y = area.y + row_index as u16; - - let mut first_text_x = None; - let mut last_text_x = None; - for x in base_x..=max_x { - let cell = &buf[(x, y)]; - if cell.symbol() != " " { - if first_text_x.is_none() { - first_text_x = Some(x); - } - last_text_x = Some(x); - } - } - - let (text_start, text_end) = match (first_text_x, last_text_x) { - // Treat indentation spaces as part of the selectable region by - // starting from the first content column to the right of the - // transcript gutter, but still clamp to the last non-space - // glyph so trailing padding is not included. - (Some(_), Some(e)) => (base_x, e), - _ => continue, - }; - - let line_start_col = if line_index == start.line_index { - start.column - } else { - 0 - }; - let line_end_col = if line_index == end.line_index { - end.column - } else { - max_x.saturating_sub(base_x) - }; - - let row_sel_start = base_x.saturating_add(line_start_col); - let row_sel_end = base_x.saturating_add(line_end_col).min(max_x); - - if row_sel_start > row_sel_end { - continue; - } - - let from_x = row_sel_start.max(text_start); - let to_x = row_sel_end.min(text_end); - - if from_x > to_x { - continue; - } - - for x in from_x..=to_x { - let cell = &mut buf[(x, y)]; - let style = cell.style(); - cell.set_style(style.add_modifier(ratatui::style::Modifier::REVERSED)); - } - } - } - - fn transcript_copy_feedback_for_footer(&mut self) -> Option { - self.transcript_copy_action.footer_feedback() - } - - fn copy_selection_key(&self) -> crate::key_hint::KeyBinding { - self.transcript_copy_ui.key_binding() - } - - /// Map a mouse position in the transcript area to a content-relative - /// selection point, if there is transcript content to select. - fn transcript_point_from_coordinates( - &self, - transcript_area: Rect, - base_x: u16, - x: u16, - y: u16, - ) -> Option { - if self.transcript_total_lines == 0 { - return None; - } - - let mut row_index = y.saturating_sub(transcript_area.y); - if row_index >= transcript_area.height { - if transcript_area.height == 0 { - return None; - } - row_index = transcript_area.height.saturating_sub(1); - } - - let max_line = self.transcript_total_lines.saturating_sub(1); - let line_index = self - .transcript_view_top - .saturating_add(usize::from(row_index)) - .min(max_line); - let column = x.saturating_sub(base_x); - - Some(TranscriptSelectionPoint { line_index, column }) - } - - async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result { - match event { - AppEvent::NewSession => { - let model = self.chat_widget.current_model().to_string(); - // Propagate selected model into config before spawning thread - self.config.model = Some(model.clone()); - let summary = session_summary( - self.chat_widget.token_usage(), - self.chat_widget.conversation_id(), - ); - self.shutdown_current_conversation().await; - if let Err(err) = self.server.remove_and_close_all_threads().await { - tracing::warn!(error = %err, "failed to close all threads"); - } - let init = crate::chatwidget::ChatWidgetInit { - config: self.config.clone(), - frame_requester: tui.frame_requester(), - app_event_tx: self.app_event_tx.clone(), - // New sessions start without prefilled message content. - initial_user_message: None, - enhanced_keys_supported: self.enhanced_keys_supported, - auth_manager: self.auth_manager.clone(), - models_manager: self.server.get_models_manager(), - feedback: self.feedback.clone(), - is_first_run: false, - model: Some(model), - otel_manager: self.otel_manager.clone(), - }; - self.chat_widget = ChatWidget::new(init, self.server.clone()); - if let Some(summary) = summary { - let mut lines: Vec> = vec![summary.usage_line.clone().into()]; - if let Some(command) = summary.resume_command { - let spans = vec!["To continue this session, run ".into(), command.cyan()]; - lines.push(spans.into()); - } - self.chat_widget.add_plain_history_lines(lines); - } - tui.frame_requester().schedule_frame(); - } - AppEvent::OpenResumePicker => { - match crate::resume_picker::run_resume_picker( - tui, - &self.config.codex_home, - &self.config.model_provider_id, - false, - ) - .await? - { - SessionSelection::Resume(path) => { - let summary = session_summary( - self.chat_widget.token_usage(), - self.chat_widget.conversation_id(), - ); - match self - .server - .resume_thread_from_rollout( - self.config.clone(), - path.clone(), - self.auth_manager.clone(), - ) - .await - { - Ok(resumed) => { - self.shutdown_current_conversation().await; - let init = self.chatwidget_init_for_forked_or_resumed_thread( - tui, - self.config.clone(), - ); - self.chat_widget = ChatWidget::new_from_existing( - init, - resumed.thread, - resumed.session_configured, - ); - if let Some(summary) = summary { - let mut lines: Vec> = - vec![summary.usage_line.clone().into()]; - if let Some(command) = summary.resume_command { - let spans = vec![ - "To continue this session, run ".into(), - command.cyan(), - ]; - lines.push(spans.into()); - } - self.chat_widget.add_plain_history_lines(lines); - } - } - Err(err) => { - let path_display = path.display(); - self.chat_widget.add_error_message(format!( - "Failed to resume session from {path_display}: {err}" - )); - } - } - } - SessionSelection::Exit - | SessionSelection::StartFresh - | SessionSelection::Fork(_) => {} - } - - // Leaving alt-screen may blank the inline viewport; force a redraw either way. - tui.frame_requester().schedule_frame(); - } - AppEvent::ForkCurrentSession => { - let summary = session_summary( - self.chat_widget.token_usage(), - self.chat_widget.conversation_id(), - ); - if let Some(path) = self.chat_widget.rollout_path() { - match self - .server - .fork_thread(usize::MAX, self.config.clone(), path.clone()) - .await - { - Ok(forked) => { - self.shutdown_current_conversation().await; - let init = self.chatwidget_init_for_forked_or_resumed_thread( - tui, - self.config.clone(), - ); - self.chat_widget = ChatWidget::new_from_existing( - init, - forked.thread, - forked.session_configured, - ); - if let Some(summary) = summary { - let mut lines: Vec> = - vec![summary.usage_line.clone().into()]; - if let Some(command) = summary.resume_command { - let spans = vec![ - "To continue this session, run ".into(), - command.cyan(), - ]; - lines.push(spans.into()); - } - self.chat_widget.add_plain_history_lines(lines); - } - } - Err(err) => { - let path_display = path.display(); - self.chat_widget.add_error_message(format!( - "Failed to fork current session from {path_display}: {err}" - )); - } - } - } else { - self.chat_widget - .add_error_message("Current session is not ready to fork yet.".to_string()); - } - - tui.frame_requester().schedule_frame(); - } - AppEvent::InsertHistoryCell(cell) => { - let cell: Arc = cell.into(); - if let Some(Overlay::Transcript(transcript)) = &mut self.overlay { - transcript.insert_cell(cell.clone()); - tui.frame_requester().schedule_frame(); - } - self.transcript_cells.push(cell.clone()); - if self.overlay.is_some() { - self.deferred_history_cells.push(cell); - } else { - tui.frame_requester().schedule_frame(); - } - } - AppEvent::StartCommitAnimation => { - if self - .commit_anim_running - .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) - .is_ok() - { - let tx = self.app_event_tx.clone(); - let running = self.commit_anim_running.clone(); - thread::spawn(move || { - while running.load(Ordering::Relaxed) { - thread::sleep(Duration::from_millis(50)); - tx.send(AppEvent::CommitTick); - } - }); - } - } - AppEvent::StopCommitAnimation => { - self.commit_anim_running.store(false, Ordering::Release); - } - AppEvent::CommitTick => { - self.chat_widget.on_commit_tick(); - } - AppEvent::CodexEvent(event) => { - if self.suppress_shutdown_complete - && matches!(event.msg, EventMsg::ShutdownComplete) - { - self.suppress_shutdown_complete = false; - return Ok(AppRunControl::Continue); - } - if let EventMsg::ListSkillsResponse(response) = &event.msg { - let cwd = self.chat_widget.config_ref().cwd.clone(); - let errors = errors_for_cwd(&cwd, response); - emit_skill_load_warnings(&self.app_event_tx, &errors); - } - self.handle_backtrack_event(&event.msg); - self.chat_widget.handle_codex_event(event); - if self.backtrack_render_pending { - tui.frame_requester().schedule_frame(); - } - } - AppEvent::Exit(mode) => match mode { - ExitMode::ShutdownFirst => self.chat_widget.submit_op(Op::Shutdown), - ExitMode::Immediate => { - return Ok(AppRunControl::Exit(ExitReason::UserRequested)); - } - }, - AppEvent::FatalExitRequest(message) => { - return Ok(AppRunControl::Exit(ExitReason::Fatal(message))); - } - AppEvent::CodexOp(op) => self.chat_widget.submit_op(op), - AppEvent::DiffResult(text) => { - // Clear the in-progress state in the bottom pane - self.chat_widget.on_diff_complete(); - // Enter alternate screen using TUI helper and build pager lines - let _ = tui.enter_alt_screen(); - let pager_lines: Vec> = if text.trim().is_empty() { - vec!["No changes detected.".italic().into()] - } else { - text.lines().map(ansi_escape_line).collect() - }; - self.overlay = Some(Overlay::new_static_with_lines( - pager_lines, - "D I F F".to_string(), - )); - tui.frame_requester().schedule_frame(); - } - AppEvent::StartFileSearch(query) => { - if !query.is_empty() { - self.file_search.on_user_query(query); - } - } - AppEvent::FileSearchResult { query, matches } => { - self.chat_widget.apply_file_search_result(query, matches); - } - AppEvent::RateLimitSnapshotFetched(snapshot) => { - self.chat_widget.on_rate_limit_snapshot(Some(snapshot)); - } - AppEvent::UpdateReasoningEffort(effort) => { - self.on_update_reasoning_effort(effort); - } - AppEvent::UpdateModel(model) => { - self.chat_widget.set_model(&model); - } - AppEvent::UpdateCollaborationMode(mode) => { - let model = mode.model().to_string(); - self.chat_widget.set_collaboration_mode(mode); - self.chat_widget.set_model(&model); - } - AppEvent::OpenReasoningPopup { model } => { - self.chat_widget.open_reasoning_popup(model); - } - AppEvent::OpenAllModelsPopup { models } => { - self.chat_widget.open_all_models_popup(models); - } - AppEvent::OpenFullAccessConfirmation { preset } => { - self.chat_widget.open_full_access_confirmation(preset); - } - AppEvent::OpenWorldWritableWarningConfirmation { - preset, - sample_paths, - extra_count, - failed_scan, - } => { - self.chat_widget.open_world_writable_warning_confirmation( - preset, - sample_paths, - extra_count, - failed_scan, - ); - } - AppEvent::OpenFeedbackNote { - category, - include_logs, - } => { - self.chat_widget.open_feedback_note(category, include_logs); - } - AppEvent::OpenFeedbackConsent { category } => { - self.chat_widget.open_feedback_consent(category); - } - AppEvent::OpenWindowsSandboxEnablePrompt { preset } => { - self.chat_widget.open_windows_sandbox_enable_prompt(preset); - } - AppEvent::OpenWindowsSandboxFallbackPrompt { preset, reason } => { - self.otel_manager - .counter("codex.windows_sandbox.fallback_prompt_shown", 1, &[]); - self.chat_widget.clear_windows_sandbox_setup_status(); - if let Some(started_at) = self.windows_sandbox.setup_started_at.take() { - self.otel_manager.record_duration( - "codex.windows_sandbox.elevated_setup_duration_ms", - started_at.elapsed(), - &[("result", "failure")], - ); - } - self.chat_widget - .open_windows_sandbox_fallback_prompt(preset, reason); - } - AppEvent::BeginWindowsSandboxElevatedSetup { preset } => { - #[cfg(target_os = "windows")] - { - let policy = preset.sandbox.clone(); - let policy_cwd = self.config.cwd.clone(); - let command_cwd = policy_cwd.clone(); - let env_map: std::collections::HashMap = - std::env::vars().collect(); - let codex_home = self.config.codex_home.clone(); - let tx = self.app_event_tx.clone(); - - // If the elevated setup already ran on this machine, don't prompt for - // elevation again - just flip the config to use the elevated path. - if codex_core::windows_sandbox::sandbox_setup_is_complete(codex_home.as_path()) - { - tx.send(AppEvent::EnableWindowsSandboxForAgentMode { - preset, - mode: WindowsSandboxEnableMode::Elevated, - }); - return Ok(AppRunControl::Continue); - } - - self.chat_widget.show_windows_sandbox_setup_status(); - self.windows_sandbox.setup_started_at = Some(Instant::now()); - let otel_manager = self.otel_manager.clone(); - tokio::task::spawn_blocking(move || { - let result = codex_core::windows_sandbox::run_elevated_setup( - &policy, - policy_cwd.as_path(), - command_cwd.as_path(), - &env_map, - codex_home.as_path(), - ); - let event = match result { - Ok(()) => { - otel_manager.counter( - "codex.windows_sandbox.elevated_setup_success", - 1, - &[], - ); - AppEvent::EnableWindowsSandboxForAgentMode { - preset: preset.clone(), - mode: WindowsSandboxEnableMode::Elevated, - } - } - Err(err) => { - otel_manager.counter( - "codex.windows_sandbox.elevated_setup_failure", - 1, - &[], - ); - tracing::error!( - error = %err, - "failed to run elevated Windows sandbox setup" - ); - AppEvent::OpenWindowsSandboxFallbackPrompt { - preset, - reason: WindowsSandboxFallbackReason::ElevationFailed, - } - } - }; - tx.send(event); - }); - } - #[cfg(not(target_os = "windows"))] - { - let _ = preset; - } - } - AppEvent::EnableWindowsSandboxForAgentMode { preset, mode } => { - #[cfg(target_os = "windows")] - { - self.chat_widget.clear_windows_sandbox_setup_status(); - if let Some(started_at) = self.windows_sandbox.setup_started_at.take() { - self.otel_manager.record_duration( - "codex.windows_sandbox.elevated_setup_duration_ms", - started_at.elapsed(), - &[("result", "success")], - ); - } - let profile = self.active_profile.as_deref(); - let feature_key = Feature::WindowsSandbox.key(); - let elevated_key = Feature::WindowsSandboxElevated.key(); - let elevated_enabled = matches!(mode, WindowsSandboxEnableMode::Elevated); - match ConfigEditsBuilder::new(&self.config.codex_home) - .with_profile(profile) - .set_feature_enabled(feature_key, true) - .set_feature_enabled(elevated_key, elevated_enabled) - .apply() - .await - { - Ok(()) => { - self.config.set_windows_sandbox_globally(true); - self.config - .set_windows_elevated_sandbox_globally(elevated_enabled); - self.chat_widget - .set_feature_enabled(Feature::WindowsSandbox, true); - self.chat_widget.set_feature_enabled( - Feature::WindowsSandboxElevated, - elevated_enabled, - ); - self.chat_widget.clear_forced_auto_mode_downgrade(); - if let Some((sample_paths, extra_count, failed_scan)) = - self.chat_widget.world_writable_warning_details() - { - self.app_event_tx.send( - AppEvent::OpenWorldWritableWarningConfirmation { - preset: Some(preset.clone()), - sample_paths, - extra_count, - failed_scan, - }, - ); - } else { - self.app_event_tx.send(AppEvent::CodexOp( - Op::OverrideTurnContext { - cwd: None, - approval_policy: Some(preset.approval), - sandbox_policy: Some(preset.sandbox.clone()), - model: None, - effort: None, - summary: None, - collaboration_mode: None, - }, - )); - self.app_event_tx - .send(AppEvent::UpdateAskForApprovalPolicy(preset.approval)); - self.app_event_tx - .send(AppEvent::UpdateSandboxPolicy(preset.sandbox.clone())); - self.chat_widget.add_info_message( - match mode { - WindowsSandboxEnableMode::Elevated => { - "Enabled elevated agent sandbox.".to_string() - } - WindowsSandboxEnableMode::Legacy => { - "Enabled non-elevated agent sandbox.".to_string() - } - }, - None, - ); - } - } - Err(err) => { - tracing::error!( - error = %err, - "failed to enable Windows sandbox feature" - ); - self.chat_widget.add_error_message(format!( - "Failed to enable the Windows sandbox feature: {err}" - )); - } - } - } - #[cfg(not(target_os = "windows"))] - { - let _ = (preset, mode); - } - } - AppEvent::PersistModelSelection { model, effort } => { - let profile = self.active_profile.as_deref(); - match ConfigEditsBuilder::new(&self.config.codex_home) - .with_profile(profile) - .set_model(Some(model.as_str()), effort) - .apply() - .await - { - Ok(()) => { - let mut message = format!("Model changed to {model}"); - if let Some(label) = Self::reasoning_label_for(&model, effort) { - message.push(' '); - message.push_str(label); - } - if let Some(profile) = profile { - message.push_str(" for "); - message.push_str(profile); - message.push_str(" profile"); - } - self.chat_widget.add_info_message(message, None); - } - Err(err) => { - tracing::error!( - error = %err, - "failed to persist model selection" - ); - if let Some(profile) = profile { - self.chat_widget.add_error_message(format!( - "Failed to save model for profile `{profile}`: {err}" - )); - } else { - self.chat_widget - .add_error_message(format!("Failed to save default model: {err}")); - } - } - } - } - AppEvent::UpdateAskForApprovalPolicy(policy) => { - self.chat_widget.set_approval_policy(policy); - } - AppEvent::UpdateSandboxPolicy(policy) => { - #[cfg(target_os = "windows")] - let policy_is_workspace_write_or_ro = matches!( - &policy, - codex_core::protocol::SandboxPolicy::WorkspaceWrite { .. } - | codex_core::protocol::SandboxPolicy::ReadOnly - ); - - if let Err(err) = self.config.sandbox_policy.set(policy.clone()) { - tracing::warn!(%err, "failed to set sandbox policy on app config"); - self.chat_widget - .add_error_message(format!("Failed to set sandbox policy: {err}")); - return Ok(AppRunControl::Continue); - } - #[cfg(target_os = "windows")] - if !matches!(&policy, codex_core::protocol::SandboxPolicy::ReadOnly) - || codex_core::get_platform_sandbox().is_some() - { - self.config.forced_auto_mode_downgraded_on_windows = false; - } - if let Err(err) = self.chat_widget.set_sandbox_policy(policy) { - tracing::warn!(%err, "failed to set sandbox policy on chat config"); - self.chat_widget - .add_error_message(format!("Failed to set sandbox policy: {err}")); - return Ok(AppRunControl::Continue); - } - - // If sandbox policy becomes workspace-write or read-only, run the Windows world-writable scan. - #[cfg(target_os = "windows")] - { - // One-shot suppression if the user just confirmed continue. - if self.windows_sandbox.skip_world_writable_scan_once { - self.windows_sandbox.skip_world_writable_scan_once = false; - return Ok(AppRunControl::Continue); - } - - let should_check = codex_core::get_platform_sandbox().is_some() - && policy_is_workspace_write_or_ro - && !self.chat_widget.world_writable_warning_hidden(); - if should_check { - let cwd = self.config.cwd.clone(); - let env_map: std::collections::HashMap = - std::env::vars().collect(); - let tx = self.app_event_tx.clone(); - let logs_base_dir = self.config.codex_home.clone(); - let sandbox_policy = self.config.sandbox_policy.get().clone(); - Self::spawn_world_writable_scan( - cwd, - env_map, - logs_base_dir, - sandbox_policy, - tx, - ); - } - } - } - AppEvent::UpdateFeatureFlags { updates } => { - if updates.is_empty() { - return Ok(AppRunControl::Continue); - } - - let mut builder = ConfigEditsBuilder::new(&self.config.codex_home) - .with_profile(self.active_profile.as_deref()); - for (feature, enabled) in &updates { - let key = feature.key(); - if *enabled { - self.config.features.enable(*feature); - self.chat_widget.set_feature_enabled(*feature, true); - builder = builder.set_feature_enabled(key, true); - } else { - self.config.features.disable(*feature); - self.chat_widget.set_feature_enabled(*feature, false); - if feature.default_enabled() { - builder = builder.set_feature_enabled(key, false); - } else { - builder = builder.with_edits([ConfigEdit::ClearPath { - segments: vec!["features".to_string(), key.to_string()], - }]); - } - } - } - - if let Err(err) = builder.apply().await { - tracing::error!(error = %err, "failed to persist feature flags"); - self.chat_widget.add_error_message(format!( - "Failed to update experimental features: {err}" - )); - } - } - AppEvent::SkipNextWorldWritableScan => { - self.windows_sandbox.skip_world_writable_scan_once = true; - } - AppEvent::UpdateFullAccessWarningAcknowledged(ack) => { - self.chat_widget.set_full_access_warning_acknowledged(ack); - } - AppEvent::UpdateWorldWritableWarningAcknowledged(ack) => { - self.chat_widget - .set_world_writable_warning_acknowledged(ack); - } - AppEvent::UpdateRateLimitSwitchPromptHidden(hidden) => { - self.chat_widget.set_rate_limit_switch_prompt_hidden(hidden); - } - AppEvent::PersistFullAccessWarningAcknowledged => { - if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) - .set_hide_full_access_warning(true) - .apply() - .await - { - tracing::error!( - error = %err, - "failed to persist full access warning acknowledgement" - ); - self.chat_widget.add_error_message(format!( - "Failed to save full access confirmation preference: {err}" - )); - } - } - AppEvent::PersistWorldWritableWarningAcknowledged => { - if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) - .set_hide_world_writable_warning(true) - .apply() - .await - { - tracing::error!( - error = %err, - "failed to persist world-writable warning acknowledgement" - ); - self.chat_widget.add_error_message(format!( - "Failed to save Agent mode warning preference: {err}" - )); - } - } - AppEvent::PersistRateLimitSwitchPromptHidden => { - if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) - .set_hide_rate_limit_model_nudge(true) - .apply() - .await - { - tracing::error!( - error = %err, - "failed to persist rate limit switch prompt preference" - ); - self.chat_widget.add_error_message(format!( - "Failed to save rate limit reminder preference: {err}" - )); - } - } - AppEvent::PersistModelMigrationPromptAcknowledged { - from_model, - to_model, - } => { - if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) - .record_model_migration_seen(from_model.as_str(), to_model.as_str()) - .apply() - .await - { - tracing::error!( - error = %err, - "failed to persist model migration prompt acknowledgement" - ); - self.chat_widget.add_error_message(format!( - "Failed to save model migration prompt preference: {err}" - )); - } - } - AppEvent::OpenApprovalsPopup => { - self.chat_widget.open_approvals_popup(); - } - AppEvent::OpenReviewBranchPicker(cwd) => { - self.chat_widget.show_review_branch_picker(&cwd).await; - } - AppEvent::OpenReviewCommitPicker(cwd) => { - self.chat_widget.show_review_commit_picker(&cwd).await; - } - AppEvent::OpenReviewCustomPrompt => { - self.chat_widget.show_review_custom_prompt(); - } - AppEvent::FullScreenApprovalRequest(request) => match request { - ApprovalRequest::ApplyPatch { cwd, changes, .. } => { - let _ = tui.enter_alt_screen(); - let diff_summary = DiffSummary::new(changes, cwd); - self.overlay = Some(Overlay::new_static_with_renderables( - vec![diff_summary.into()], - "P A T C H".to_string(), - )); - } - ApprovalRequest::Exec { command, .. } => { - let _ = tui.enter_alt_screen(); - let full_cmd = strip_bash_lc_and_escape(&command); - let full_cmd_lines = highlight_bash_to_lines(&full_cmd); - self.overlay = Some(Overlay::new_static_with_lines( - full_cmd_lines, - "E X E C".to_string(), - )); - } - ApprovalRequest::McpElicitation { - server_name, - message, - .. - } => { - let _ = tui.enter_alt_screen(); - let paragraph = Paragraph::new(vec![ - Line::from(vec!["Server: ".into(), server_name.bold()]), - Line::from(""), - Line::from(message), - ]) - .wrap(Wrap { trim: false }); - self.overlay = Some(Overlay::new_static_with_renderables( - vec![Box::new(paragraph)], - "E L I C I T A T I O N".to_string(), - )); - } - }, - } - Ok(AppRunControl::Continue) - } - - fn reasoning_label(reasoning_effort: Option) -> &'static str { - match reasoning_effort { - Some(ReasoningEffortConfig::Minimal) => "minimal", - Some(ReasoningEffortConfig::Low) => "low", - Some(ReasoningEffortConfig::Medium) => "medium", - Some(ReasoningEffortConfig::High) => "high", - Some(ReasoningEffortConfig::XHigh) => "xhigh", - None | Some(ReasoningEffortConfig::None) => "default", - } - } - - fn reasoning_label_for( - model: &str, - reasoning_effort: Option, - ) -> Option<&'static str> { - (!model.starts_with("codex-auto-")).then(|| Self::reasoning_label(reasoning_effort)) - } - - pub(crate) fn token_usage(&self) -> codex_core::protocol::TokenUsage { - self.chat_widget.token_usage() - } - - fn on_update_reasoning_effort(&mut self, effort: Option) { - // TODO(aibrahim): Remove this and don't use config as a state object. - // Instead, explicitly pass the stored collaboration mode's effort into new sessions. - self.config.model_reasoning_effort = effort; - self.chat_widget.set_reasoning_effort(effort); - } - - async fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) { - match key_event { - KeyEvent { - code: KeyCode::Char('t'), - modifiers: crossterm::event::KeyModifiers::CONTROL, - kind: KeyEventKind::Press, - .. - } => { - // Enter alternate screen and set viewport to full size. - let _ = tui.enter_alt_screen(); - self.overlay = Some(Overlay::new_transcript(self.transcript_cells.clone())); - tui.frame_requester().schedule_frame(); - } - // Esc primes/advances backtracking only in normal (not working) mode - // with the composer focused and empty. In any other state, forward - // Esc so the active UI (e.g. status indicator, modals, popups) - // handles it. - KeyEvent { - code: KeyCode::Esc, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - } => { - if self.chat_widget.is_normal_backtrack_mode() - && self.chat_widget.composer_is_empty() - { - self.handle_backtrack_esc_key(tui); - } else { - self.chat_widget.handle_key_event(key_event); - } - } - KeyEvent { - code: KeyCode::Char(ch), - modifiers, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - } if self.transcript_copy_ui.is_copy_key(ch, modifiers) => { - let size = tui.terminal.last_known_screen_size; - let width = size.width; - let height = size.height; - if width == 0 || height == 0 { - return; - } - - let chat_height = self.chat_widget.desired_height(width); - if self.transcript_copy_action.copy_and_handle( - tui, - chat_height, - &self.transcript_cells, - self.transcript_selection, - ) { - self.transcript_selection = TranscriptSelection::default(); - } - } - KeyEvent { - code: KeyCode::PageUp, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - } => { - let size = tui.terminal.last_known_screen_size; - let width = size.width; - let height = size.height; - if width > 0 && height > 0 { - let chat_height = self.chat_widget.desired_height(width); - if chat_height < height { - let transcript_height = height.saturating_sub(chat_height); - if transcript_height > 0 { - let delta = -i32::from(transcript_height); - self.scroll_transcript( - tui, - delta, - usize::from(transcript_height), - width, - true, - ); - } - } - } - } - KeyEvent { - code: KeyCode::PageDown, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - } => { - let size = tui.terminal.last_known_screen_size; - let width = size.width; - let height = size.height; - if width > 0 && height > 0 { - let chat_height = self.chat_widget.desired_height(width); - if chat_height < height { - let transcript_height = height.saturating_sub(chat_height); - if transcript_height > 0 { - let delta = i32::from(transcript_height); - self.scroll_transcript( - tui, - delta, - usize::from(transcript_height), - width, - true, - ); - } - } - } - } - KeyEvent { - code: KeyCode::Home, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - } => { - if !self.transcript_cells.is_empty() { - self.transcript_scroll = TranscriptScroll::Scrolled { - cell_index: 0, - line_in_cell: 0, - }; - tui.frame_requester().schedule_frame(); - } - } - KeyEvent { - code: KeyCode::End, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - } => { - self.transcript_scroll = TranscriptScroll::ToBottom; - tui.frame_requester().schedule_frame(); - } - // Enter confirms backtrack when primed + count > 0. Otherwise pass to widget. - KeyEvent { - code: KeyCode::Enter, - kind: KeyEventKind::Press, - .. - } if self.backtrack.primed - && self.backtrack.nth_user_message != usize::MAX - && self.chat_widget.composer_is_empty() => - { - if let Some(selection) = self.confirm_backtrack_from_main() { - self.apply_backtrack_selection(tui, selection); - } - } - KeyEvent { - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - } => { - // Any non-Esc key press should cancel a primed backtrack. - // This avoids stale "Esc-primed" state after the user starts typing - // (even if they later backspace to empty). - if key_event.code != KeyCode::Esc && self.backtrack.primed { - self.reset_backtrack_state(); - } - self.chat_widget.handle_key_event(key_event); - } - _ => { - // Ignore Release key events. - } - }; - } - - #[cfg(target_os = "windows")] - fn spawn_world_writable_scan( - cwd: PathBuf, - env_map: std::collections::HashMap, - logs_base_dir: PathBuf, - sandbox_policy: codex_core::protocol::SandboxPolicy, - tx: AppEventSender, - ) { - tokio::task::spawn_blocking(move || { - let result = codex_windows_sandbox::apply_world_writable_scan_and_denies( - &logs_base_dir, - &cwd, - &env_map, - &sandbox_policy, - Some(logs_base_dir.as_path()), - ); - if result.is_err() { - // Scan failed: warn without examples. - tx.send(AppEvent::OpenWorldWritableWarningConfirmation { - preset: None, - sample_paths: Vec::new(), - extra_count: 0usize, - failed_scan: true, - }); - } - }); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::app_backtrack::BacktrackState; - use crate::app_backtrack::user_count; - use crate::chatwidget::tests::make_chatwidget_manual_with_sender; - use crate::file_search::FileSearchManager; - use crate::history_cell::AgentMessageCell; - use crate::history_cell::HistoryCell; - use crate::history_cell::UserHistoryCell; - use crate::history_cell::new_session_info; - use crate::transcript_copy_ui::CopySelectionShortcut; - use crate::tui::scrolling::TranscriptLineMeta; - use codex_core::AuthManager; - use codex_core::CodexAuth; - use codex_core::ThreadManager; - use codex_core::config::ConfigBuilder; - use codex_core::models_manager::manager::ModelsManager; - use codex_core::protocol::AskForApproval; - use codex_core::protocol::Event; - use codex_core::protocol::EventMsg; - use codex_core::protocol::SandboxPolicy; - use codex_core::protocol::SessionConfiguredEvent; - use codex_core::protocol::SessionSource; - use codex_otel::OtelManager; - use codex_protocol::ThreadId; - use insta::assert_snapshot; - use pretty_assertions::assert_eq; - use ratatui::prelude::Line; - use std::path::PathBuf; - use std::sync::Arc; - use std::sync::atomic::AtomicBool; - use tempfile::tempdir; - - async fn make_test_app() -> App { - let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await; - let config = chat_widget.config_ref().clone(); - let server = Arc::new(ThreadManager::with_models_provider( - CodexAuth::from_api_key("Test API Key"), - config.model_provider.clone(), - )); - let auth_manager = - AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); - let model = ModelsManager::get_model_offline(config.model.as_deref()); - let otel_manager = test_otel_manager(&config, model.as_str()); - - App { - server, - otel_manager, - app_event_tx, - chat_widget, - auth_manager, - config, - active_profile: None, - file_search, - transcript_cells: Vec::new(), - transcript_view_cache: TranscriptViewCache::new(), - transcript_scroll: TranscriptScroll::default(), - transcript_selection: TranscriptSelection::default(), - transcript_multi_click: TranscriptMultiClick::default(), - transcript_view_top: 0, - transcript_total_lines: 0, - transcript_copy_ui: TranscriptCopyUi::new_with_shortcut( - CopySelectionShortcut::CtrlShiftC, - ), - transcript_copy_action: TranscriptCopyAction::default(), - transcript_scrollbar_ui: TranscriptScrollbarUi::default(), - overlay: None, - deferred_history_cells: Vec::new(), - has_emitted_history_lines: false, - enhanced_keys_supported: false, - commit_anim_running: Arc::new(AtomicBool::new(false)), - scroll_config: ScrollConfig::default(), - scroll_state: MouseScrollState::default(), - backtrack: BacktrackState::default(), - backtrack_render_pending: false, - feedback: codex_feedback::CodexFeedback::new(), - pending_update_action: None, - suppress_shutdown_complete: false, - windows_sandbox: WindowsSandboxState::default(), - } - } - - async fn make_test_app_with_channels() -> ( - App, - tokio::sync::mpsc::UnboundedReceiver, - tokio::sync::mpsc::UnboundedReceiver, - ) { - let (chat_widget, app_event_tx, rx, op_rx) = make_chatwidget_manual_with_sender().await; - let config = chat_widget.config_ref().clone(); - let server = Arc::new(ThreadManager::with_models_provider( - CodexAuth::from_api_key("Test API Key"), - config.model_provider.clone(), - )); - let auth_manager = - AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); - let model = ModelsManager::get_model_offline(config.model.as_deref()); - let otel_manager = test_otel_manager(&config, model.as_str()); - - ( - App { - server, - otel_manager, - app_event_tx, - chat_widget, - auth_manager, - config, - active_profile: None, - file_search, - transcript_cells: Vec::new(), - transcript_view_cache: TranscriptViewCache::new(), - transcript_scroll: TranscriptScroll::default(), - transcript_selection: TranscriptSelection::default(), - transcript_multi_click: TranscriptMultiClick::default(), - transcript_view_top: 0, - transcript_total_lines: 0, - transcript_copy_ui: TranscriptCopyUi::new_with_shortcut( - CopySelectionShortcut::CtrlShiftC, - ), - transcript_copy_action: TranscriptCopyAction::default(), - transcript_scrollbar_ui: TranscriptScrollbarUi::default(), - overlay: None, - deferred_history_cells: Vec::new(), - has_emitted_history_lines: false, - enhanced_keys_supported: false, - commit_anim_running: Arc::new(AtomicBool::new(false)), - scroll_config: ScrollConfig::default(), - scroll_state: MouseScrollState::default(), - backtrack: BacktrackState::default(), - backtrack_render_pending: false, - feedback: codex_feedback::CodexFeedback::new(), - pending_update_action: None, - suppress_shutdown_complete: false, - windows_sandbox: WindowsSandboxState::default(), - }, - rx, - op_rx, - ) - } - - fn test_otel_manager(config: &Config, model: &str) -> OtelManager { - let model_info = ModelsManager::construct_model_info_offline(model, config); - OtelManager::new( - ThreadId::new(), - model, - model_info.slug.as_str(), - None, - None, - None, - false, - "test".to_string(), - SessionSource::Cli, - ) - } - - fn all_model_presets() -> Vec { - codex_core::models_manager::model_presets::all_model_presets().clone() - } - - fn model_migration_copy_to_plain_text( - copy: &crate::model_migration::ModelMigrationCopy, - ) -> String { - if let Some(markdown) = copy.markdown.as_ref() { - return markdown.clone(); - } - let mut s = String::new(); - for span in ©.heading { - s.push_str(&span.content); - } - s.push('\n'); - s.push('\n'); - for line in ©.content { - for span in &line.spans { - s.push_str(&span.content); - } - s.push('\n'); - } - s - } - - #[tokio::test] - async fn model_migration_prompt_only_shows_for_deprecated_models() { - let seen = BTreeMap::new(); - assert!(should_show_model_migration_prompt( - "gpt-5", - "gpt-5.1", - &seen, - &all_model_presets() - )); - assert!(should_show_model_migration_prompt( - "gpt-5-codex", - "gpt-5.1-codex", - &seen, - &all_model_presets() - )); - assert!(should_show_model_migration_prompt( - "gpt-5-codex-mini", - "gpt-5.1-codex-mini", - &seen, - &all_model_presets() - )); - assert!(should_show_model_migration_prompt( - "gpt-5.1-codex", - "gpt-5.1-codex-max", - &seen, - &all_model_presets() - )); - assert!(!should_show_model_migration_prompt( - "gpt-5.1-codex", - "gpt-5.1-codex", - &seen, - &all_model_presets() - )); - } - - #[tokio::test] - async fn model_migration_prompt_shows_for_hidden_model() { - let codex_home = tempdir().expect("temp codex home"); - let config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .build() - .await - .expect("config"); - - let available_models = all_model_presets(); - let current = available_models - .iter() - .find(|preset| preset.model == "gpt-5.1-codex") - .cloned() - .expect("gpt-5.1-codex preset present"); - assert!( - !current.show_in_picker, - "expected gpt-5.1-codex to be hidden from picker for this test" - ); - - let upgrade = current.upgrade.as_ref().expect("upgrade configured"); - assert!( - should_show_model_migration_prompt( - ¤t.model, - &upgrade.id, - &config.notices.model_migrations, - &available_models, - ), - "expected migration prompt to be eligible for hidden model" - ); - - let target = available_models - .iter() - .find(|preset| preset.model == upgrade.id) - .cloned() - .expect("upgrade target present"); - let target_description = - (!target.description.is_empty()).then(|| target.description.clone()); - let can_opt_out = true; - let copy = migration_copy_for_models( - ¤t.model, - &upgrade.id, - target.display_name, - target_description, - upgrade.migration_markdown.clone(), - can_opt_out, - ); - - assert_snapshot!( - "model_migration_prompt_shows_for_hidden_model", - model_migration_copy_to_plain_text(©) - ); - } - - #[tokio::test] - async fn transcript_selection_copy_includes_offscreen_lines() { - let mut app = make_test_app().await; - app.transcript_cells = vec![Arc::new(AgentMessageCell::new( - vec![ - Line::from("one"), - Line::from("two"), - Line::from("three"), - Line::from("four"), - ], - true, - ))]; - - app.transcript_view_top = 2; - app.transcript_selection.anchor = Some(TranscriptSelectionPoint { - line_index: 0, - column: 0, - }); - app.transcript_selection.head = Some(TranscriptSelectionPoint { - line_index: 3, - column: u16::MAX, - }); - - let text = crate::transcript_copy::selection_to_copy_text_for_cells( - &app.transcript_cells, - app.transcript_selection, - 40, - ) - .expect("expected text"); - assert_eq!(text, "one\ntwo\nthree\nfour"); - } - - #[tokio::test] - async fn model_migration_prompt_respects_hide_flag_and_self_target() { - let mut seen = BTreeMap::new(); - seen.insert("gpt-5".to_string(), "gpt-5.1".to_string()); - assert!(!should_show_model_migration_prompt( - "gpt-5", - "gpt-5.1", - &seen, - &all_model_presets() - )); - assert!(!should_show_model_migration_prompt( - "gpt-5.1", - "gpt-5.1", - &seen, - &all_model_presets() - )); - } - - #[tokio::test] - async fn update_reasoning_effort_updates_collaboration_mode() { - let mut app = make_test_app().await; - app.chat_widget - .set_reasoning_effort(Some(ReasoningEffortConfig::Medium)); - - app.on_update_reasoning_effort(Some(ReasoningEffortConfig::High)); - - assert_eq!( - app.chat_widget.current_reasoning_effort(), - Some(ReasoningEffortConfig::High) - ); - assert_eq!( - app.config.model_reasoning_effort, - Some(ReasoningEffortConfig::High) - ); - } - - #[tokio::test] - async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { - let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await; - - let user_cell = |text: &str| -> Arc { - Arc::new(UserHistoryCell { - message: text.to_string(), - text_elements: Vec::new(), - local_image_paths: Vec::new(), - }) as Arc - }; - let agent_cell = |text: &str| -> Arc { - Arc::new(AgentMessageCell::new( - vec![Line::from(text.to_string())], - true, - )) as Arc - }; - - let make_header = |is_first| { - let event = SessionConfiguredEvent { - session_id: ThreadId::new(), - forked_from_id: None, - model: "gpt-test".to_string(), - model_provider_id: "test-provider".to_string(), - approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, - cwd: PathBuf::from("/home/user/project"), - reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, - initial_messages: None, - rollout_path: PathBuf::new(), - }; - Arc::new(new_session_info( - app.chat_widget.config_ref(), - app.chat_widget.current_model(), - event, - is_first, - false, - codex_protocol::config_types::CollaborationMode::Custom( - codex_protocol::config_types::Settings { - model: "gpt-test".to_string(), - reasoning_effort: None, - developer_instructions: None, - }, - ), - )) as Arc - }; - - // Simulate a transcript with duplicated history (e.g., from prior backtracks) - // and an edited turn appended after a session header boundary. - app.transcript_cells = vec![ - make_header(true), - user_cell("first question"), - agent_cell("answer first"), - user_cell("follow-up"), - agent_cell("answer follow-up"), - make_header(false), - user_cell("first question"), - agent_cell("answer first"), - user_cell("follow-up (edited)"), - agent_cell("answer edited"), - ]; - - assert_eq!(user_count(&app.transcript_cells), 2); - - let base_id = ThreadId::new(); - app.chat_widget.handle_codex_event(Event { - id: String::new(), - msg: EventMsg::SessionConfigured(SessionConfiguredEvent { - session_id: base_id, - forked_from_id: None, - model: "gpt-test".to_string(), - model_provider_id: "test-provider".to_string(), - approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, - cwd: PathBuf::from("/home/user/project"), - reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, - initial_messages: None, - rollout_path: PathBuf::new(), - }), - }); - - app.backtrack.base_id = Some(base_id); - app.backtrack.primed = true; - app.backtrack.nth_user_message = user_count(&app.transcript_cells).saturating_sub(1); - - let selection = app - .confirm_backtrack_from_main() - .expect("backtrack selection"); - assert_eq!(selection.nth_user_message, 1); - assert_eq!(selection.prefill, "follow-up (edited)"); - - app.apply_backtrack_rollback(selection); - - let mut rollback_turns = None; - while let Ok(op) = op_rx.try_recv() { - if let Op::ThreadRollback { num_turns } = op { - rollback_turns = Some(num_turns); - } - } - - assert_eq!(rollback_turns, Some(1)); - } - - #[tokio::test] - async fn transcript_selection_moves_with_scroll() { - use ratatui::buffer::Buffer; - use ratatui::layout::Rect; - - let mut app = make_test_app().await; - app.transcript_total_lines = 3; - - let area = Rect { - x: 0, - y: 0, - width: 10, - height: 2, - }; - - // Anchor selection to logical line 1, columns 2..4. - app.transcript_selection = TranscriptSelection { - anchor: Some(TranscriptSelectionPoint { - line_index: 1, - column: 2, - }), - head: Some(TranscriptSelectionPoint { - line_index: 1, - column: 4, - }), - }; - - // First render: top of view is line 0, so line 1 maps to the second row. - app.transcript_view_top = 0; - let mut buf = Buffer::empty(area); - for x in 2..area.width { - buf[(x, 0)].set_symbol("A"); - buf[(x, 1)].set_symbol("B"); - } - - app.apply_transcript_selection(area, &mut buf); - - // No selection should be applied to the first row when the view is anchored at the top. - for x in 0..area.width { - let cell = &buf[(x, 0)]; - assert!(cell.style().add_modifier.is_empty()); - } - - // After scrolling down by one line, the same logical line should now be - // rendered on the first row, and the highlight should move with it. - app.transcript_view_top = 1; - let mut buf_scrolled = Buffer::empty(area); - for x in 2..area.width { - buf_scrolled[(x, 0)].set_symbol("B"); - buf_scrolled[(x, 1)].set_symbol("C"); - } - - app.apply_transcript_selection(area, &mut buf_scrolled); - - // After scrolling, the selection should now be applied on the first row rather than the - // second. - for x in 0..area.width { - let cell = &buf_scrolled[(x, 1)]; - assert!(cell.style().add_modifier.is_empty()); - } - } - - #[tokio::test] - async fn transcript_selection_renders_copy_affordance() { - use ratatui::buffer::Buffer; - use ratatui::layout::Rect; - - let mut app = make_test_app().await; - app.transcript_total_lines = 3; - app.transcript_view_top = 0; - - let area = Rect { - x: 0, - y: 0, - width: 60, - height: 3, - }; - - app.transcript_selection = TranscriptSelection { - anchor: Some(TranscriptSelectionPoint { - line_index: 1, - column: 2, - }), - head: Some(TranscriptSelectionPoint { - line_index: 1, - column: 6, - }), - }; - - let mut buf = Buffer::empty(area); - for y in 0..area.height { - for x in 2..area.width.saturating_sub(1) { - buf[(x, y)].set_symbol("X"); - } - } - - app.apply_transcript_selection(area, &mut buf); - let anchor = app.transcript_selection.anchor.expect("anchor"); - let head = app.transcript_selection.head.expect("head"); - app.transcript_copy_ui.render_copy_pill( - area, - &mut buf, - (anchor.line_index, anchor.column), - (head.line_index, head.column), - app.transcript_view_top, - app.transcript_total_lines, - ); - - let mut s = String::new(); - for y in area.y..area.bottom() { - for x in area.x..area.right() { - s.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); - } - s.push('\n'); - } - - assert!(s.contains("copy")); - assert!(s.contains("ctrl + shift + c")); - assert!(app.transcript_copy_ui.hit_test(10, 2)); - } - - #[tokio::test] - async fn transcript_selection_renders_ctrl_y_copy_affordance_in_vscode_mode() { - use ratatui::buffer::Buffer; - use ratatui::layout::Rect; - - let mut app = make_test_app().await; - app.transcript_copy_ui = TranscriptCopyUi::new_with_shortcut(CopySelectionShortcut::CtrlY); - app.transcript_total_lines = 3; - app.transcript_view_top = 0; - - let area = Rect { - x: 0, - y: 0, - width: 60, - height: 3, - }; - - app.transcript_selection = TranscriptSelection { - anchor: Some(TranscriptSelectionPoint { - line_index: 1, - column: 2, - }), - head: Some(TranscriptSelectionPoint { - line_index: 1, - column: 6, - }), - }; - - let mut buf = Buffer::empty(area); - for y in 0..area.height { - for x in 2..area.width.saturating_sub(1) { - buf[(x, y)].set_symbol("X"); - } - } - - app.apply_transcript_selection(area, &mut buf); - let anchor = app.transcript_selection.anchor.expect("anchor"); - let head = app.transcript_selection.head.expect("head"); - app.transcript_copy_ui.render_copy_pill( - area, - &mut buf, - (anchor.line_index, anchor.column), - (head.line_index, head.column), - app.transcript_view_top, - app.transcript_total_lines, - ); - - let mut s = String::new(); - for y in area.y..area.bottom() { - for x in area.x..area.right() { - s.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); - } - s.push('\n'); - } - - assert!(s.contains("copy")); - assert!(s.contains("ctrl + y")); - assert!(!s.contains("ctrl + shift + c")); - assert!(app.transcript_copy_ui.hit_test(10, 2)); - } - - #[tokio::test] - async fn transcript_selection_hides_copy_affordance_while_dragging() { - use ratatui::buffer::Buffer; - use ratatui::layout::Rect; - - let mut app = make_test_app().await; - app.transcript_total_lines = 3; - app.transcript_view_top = 0; - app.transcript_copy_ui.set_dragging(true); - - let area = Rect { - x: 0, - y: 0, - width: 60, - height: 3, - }; - - app.transcript_selection = TranscriptSelection { - anchor: Some(TranscriptSelectionPoint { - line_index: 1, - column: 2, - }), - head: Some(TranscriptSelectionPoint { - line_index: 1, - column: 6, - }), - }; - - let mut buf = Buffer::empty(area); - for y in 0..area.height { - for x in 2..area.width.saturating_sub(1) { - buf[(x, y)].set_symbol("X"); - } - } - - let anchor = app.transcript_selection.anchor.expect("anchor"); - let head = app.transcript_selection.head.expect("head"); - app.transcript_copy_ui.render_copy_pill( - area, - &mut buf, - (anchor.line_index, anchor.column), - (head.line_index, head.column), - app.transcript_view_top, - app.transcript_total_lines, - ); - - let mut s = String::new(); - for y in area.y..area.bottom() { - for x in area.x..area.right() { - s.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); - } - s.push('\n'); - } - - assert!(!s.contains("copy")); - assert!(!app.transcript_copy_ui.hit_test(10, 2)); - } - - #[tokio::test] - async fn new_session_requests_shutdown_for_previous_conversation() { - let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; - - let conversation_id = ThreadId::new(); - let event = SessionConfiguredEvent { - session_id: conversation_id, - forked_from_id: None, - model: "gpt-test".to_string(), - model_provider_id: "test-provider".to_string(), - approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, - cwd: PathBuf::from("/home/user/project"), - reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, - initial_messages: None, - rollout_path: PathBuf::new(), - }; - - app.chat_widget.handle_codex_event(Event { - id: String::new(), - msg: EventMsg::SessionConfigured(event), - }); - - while app_event_rx.try_recv().is_ok() {} - while op_rx.try_recv().is_ok() {} - - app.shutdown_current_conversation().await; - - match op_rx.try_recv() { - Ok(Op::Shutdown) => {} - Ok(other) => panic!("expected Op::Shutdown, got {other:?}"), - Err(_) => panic!("expected shutdown op to be sent"), - } - } - - #[tokio::test] - async fn session_summary_skip_zero_usage() { - assert!(session_summary(TokenUsage::default(), None).is_none()); - } - - #[tokio::test] - async fn render_lines_to_ansi_pads_user_rows_to_full_width() { - let line: Line<'static> = Line::from("hi"); - let lines = vec![line]; - let line_meta = vec![TranscriptLineMeta::CellLine { - cell_index: 0, - line_in_cell: 0, - }]; - let is_user_cell = vec![true]; - let width: u16 = 10; - - let rendered = crate::transcript_render::render_lines_to_ansi( - &lines, - &line_meta, - &is_user_cell, - width, - ); - assert_eq!(rendered.len(), 1); - assert!(rendered[0].contains("hi")); - } - - #[tokio::test] - async fn session_summary_includes_resume_hint() { - let usage = TokenUsage { - input_tokens: 10, - output_tokens: 2, - total_tokens: 12, - ..Default::default() - }; - let conversation = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap(); - - let summary = session_summary(usage, Some(conversation)).expect("summary"); - assert_eq!( - summary.usage_line, - "Token usage: total=12 input=10 output=2" - ); - assert_eq!( - summary.resume_command, - Some("codex resume 123e4567-e89b-12d3-a456-426614174000".to_string()) - ); - } -} diff --git a/codex-rs/tui2/src/app_backtrack.rs b/codex-rs/tui2/src/app_backtrack.rs deleted file mode 100644 index 81bf398bf94..00000000000 --- a/codex-rs/tui2/src/app_backtrack.rs +++ /dev/null @@ -1,676 +0,0 @@ -//! Backtracking and transcript overlay event routing. -//! -//! This file owns backtrack mode (Esc/Enter navigation in the transcript overlay) and also -//! mediates a key rendering boundary for the transcript overlay. -//! -//! Overall goal: keep the main chat view and the transcript overlay in sync while allowing -//! users to "rewind" to an earlier user message. We stage a rollback request, wait for core to -//! confirm it, then trim the local transcript to the matching history boundary. This avoids UI -//! state diverging from the agent if a rollback fails or targets a different thread. -//! -//! Backtrack operates as a small state machine: -//! - The first `Esc` in the main view "primes" the feature and captures a base conversation id. -//! - A subsequent `Esc` opens the transcript overlay (`Ctrl+T`) and highlights a user message. -//! - `Enter` requests a rollback from core and records a `pending_rollback` guard. -//! - Only after receiving `EventMsg::ThreadRolledBack` do we trim local transcript state and -//! schedule a one-time scrollback refresh. -//! -//! The transcript overlay (`Ctrl+T`) renders committed transcript cells plus a render-only live -//! tail derived from the current in-flight `ChatWidget.active_cell`. -//! -//! That live tail is kept in sync during `TuiEvent::Draw` handling for `Overlay::Transcript` by -//! asking `ChatWidget` for an active-cell cache key and transcript lines and by passing them into -//! `TranscriptOverlay::sync_live_tail`. This preserves the invariant that the overlay reflects -//! both committed history and in-flight activity without changing flush or coalescing behavior. - -use std::any::TypeId; -use std::sync::Arc; - -use crate::app::App; -use crate::history_cell::SessionInfoCell; -use crate::history_cell::UserHistoryCell; -use crate::pager_overlay::Overlay; -use crate::tui; -use crate::tui::TuiEvent; -use codex_core::protocol::CodexErrorInfo; -use codex_core::protocol::ErrorEvent; -use codex_core::protocol::EventMsg; -use codex_core::protocol::Op; -use codex_protocol::ThreadId; -use color_eyre::eyre::Result; -use crossterm::event::KeyCode; -use crossterm::event::KeyEvent; -use crossterm::event::KeyEventKind; - -/// Aggregates all backtrack-related state used by the App. -#[derive(Default)] -pub(crate) struct BacktrackState { - /// True when Esc has primed backtrack mode in the main view. - pub(crate) primed: bool, - /// Session id of the base conversation to rollback. - /// - /// If the current conversation changes, backtrack selections become invalid and must be - /// ignored. - pub(crate) base_id: Option, - /// Index of the currently highlighted user message. - /// - /// This is an index into the filtered "user messages since the last session start" view, - /// not an index into `transcript_cells`. `usize::MAX` indicates "no selection". - pub(crate) nth_user_message: usize, - /// True when the transcript overlay is showing a backtrack preview. - pub(crate) overlay_preview_active: bool, - /// Pending rollback request awaiting confirmation from core. - /// - /// This acts as a guardrail: once we request a rollback, we block additional backtrack - /// submissions until core responds with either a success or failure event. - pub(crate) pending_rollback: Option, -} - -/// A user-visible backtrack choice that can be confirmed into a rollback request. -#[derive(Debug, Clone)] -pub(crate) struct BacktrackSelection { - /// The selected user message, counted from the most recent session start. - /// - /// This value is used both to compute the rollback depth and to trim the local transcript - /// after core confirms the rollback. - pub(crate) nth_user_message: usize, - /// Composer prefill derived from the selected user message. - /// - /// This is applied immediately on selection confirmation; if the rollback fails, the prefill - /// remains as a convenience so the user can retry or edit. - pub(crate) prefill: String, -} - -/// An in-flight rollback requested from core. -/// -/// We keep enough information to apply the corresponding local trim only if the response targets -/// the same active conversation we issued the request for. -#[derive(Debug, Clone)] -pub(crate) struct PendingBacktrackRollback { - pub(crate) selection: BacktrackSelection, - pub(crate) thread_id: Option, -} - -impl App { - /// Route overlay events while the transcript overlay is active. - /// - /// If backtrack preview is active, Esc / Left steps selection, Right steps forward, Enter - /// confirms. Otherwise, Esc begins preview mode and all other events are forwarded to the - /// overlay. - pub(crate) async fn handle_backtrack_overlay_event( - &mut self, - tui: &mut tui::Tui, - event: TuiEvent, - ) -> Result { - if self.backtrack.overlay_preview_active { - match event { - TuiEvent::Key(KeyEvent { - code: KeyCode::Esc, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - }) => { - self.overlay_step_backtrack(tui, event)?; - Ok(true) - } - TuiEvent::Key(KeyEvent { - code: KeyCode::Left, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - }) => { - self.overlay_step_backtrack(tui, event)?; - Ok(true) - } - TuiEvent::Key(KeyEvent { - code: KeyCode::Right, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - }) => { - self.overlay_step_backtrack_forward(tui, event)?; - Ok(true) - } - TuiEvent::Key(KeyEvent { - code: KeyCode::Enter, - kind: KeyEventKind::Press, - .. - }) => { - self.overlay_confirm_backtrack(tui); - Ok(true) - } - // Catchall: forward any other events to the overlay widget. - _ => { - self.overlay_forward_event(tui, event)?; - Ok(true) - } - } - } else if let TuiEvent::Key(KeyEvent { - code: KeyCode::Esc, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - }) = event - { - // First Esc in transcript overlay: begin backtrack preview at latest user message. - self.begin_overlay_backtrack_preview(tui); - Ok(true) - } else { - // Not in backtrack mode: forward events to the overlay widget. - self.overlay_forward_event(tui, event)?; - Ok(true) - } - } - - /// Handle global Esc presses for backtracking when no overlay is present. - pub(crate) fn handle_backtrack_esc_key(&mut self, tui: &mut tui::Tui) { - if !self.chat_widget.composer_is_empty() { - return; - } - - if !self.backtrack.primed { - self.prime_backtrack(); - } else if self.overlay.is_none() { - self.open_backtrack_preview(tui); - } else if self.backtrack.overlay_preview_active { - self.step_backtrack_and_highlight(tui); - } - } - - /// Stage a backtrack and request thread history from the agent. - /// - /// We send the rollback request immediately, but we only mutate the transcript after core - /// confirms success so the UI cannot get ahead of the actual thread state. - /// - /// The composer prefill is applied immediately as a UX convenience; it does not imply that - /// core has accepted the rollback. - pub(crate) fn apply_backtrack_rollback(&mut self, selection: BacktrackSelection) { - let user_total = user_count(&self.transcript_cells); - if user_total == 0 { - return; - } - - if self.backtrack.pending_rollback.is_some() { - self.chat_widget - .add_error_message("Backtrack rollback already in progress.".to_string()); - return; - } - - let num_turns = user_total.saturating_sub(selection.nth_user_message); - let num_turns = u32::try_from(num_turns).unwrap_or(u32::MAX); - if num_turns == 0 { - return; - } - - let prefill = selection.prefill.clone(); - self.backtrack.pending_rollback = Some(PendingBacktrackRollback { - selection, - thread_id: self.chat_widget.conversation_id(), - }); - self.chat_widget.submit_op(Op::ThreadRollback { num_turns }); - if !prefill.is_empty() { - // TODO: Rehydrate text_elements/local_image_paths from the selected user cell so - // backtrack preserves image placeholders and attachments. - self.chat_widget - .set_composer_text(prefill, Vec::new(), Vec::new()); - } - } - - /// Open transcript overlay (enters alternate screen and shows full transcript). - pub(crate) fn open_transcript_overlay(&mut self, tui: &mut tui::Tui) { - let _ = tui.enter_alt_screen(); - self.overlay = Some(Overlay::new_transcript(self.transcript_cells.clone())); - tui.frame_requester().schedule_frame(); - } - - /// Close transcript overlay and restore normal UI. - /// - /// Any history emitted while the overlay was open is flushed to the normal-buffer queue here. - /// - /// Importantly, we defer *cells* (not rendered lines) so we can render them against the current - /// width on close and avoid baking width-derived wrapping based on an earlier viewport size. - /// (This matters if/when scrollback printing is enabled; `Tui::insert_history_lines` currently - /// queues lines without printing them during the main draw loop.) - pub(crate) fn close_transcript_overlay(&mut self, tui: &mut tui::Tui) { - let _ = tui.leave_alt_screen(); - let was_backtrack = self.backtrack.overlay_preview_active; - if !self.deferred_history_cells.is_empty() { - let cells = std::mem::take(&mut self.deferred_history_cells); - let width = tui.terminal.last_known_screen_size.width; - let mut lines: Vec> = Vec::new(); - for cell in cells { - let mut display = cell.display_lines(width); - if display.is_empty() { - continue; - } - - // Only insert a separating blank line for new cells that are not part of an - // ongoing stream. Streaming continuations should not accrue extra blank lines - // between chunks. - if !cell.is_stream_continuation() { - if self.has_emitted_history_lines { - display.insert(0, ratatui::text::Line::from("")); - } else { - self.has_emitted_history_lines = true; - } - } - - lines.extend(display); - } - if !lines.is_empty() { - tui.insert_history_lines(lines); - } - } - self.overlay = None; - self.backtrack.overlay_preview_active = false; - if was_backtrack { - // Ensure backtrack state is fully reset when overlay closes (e.g. via 'q'). - self.reset_backtrack_state(); - } - } - - /// Re-render the full transcript into the terminal scrollback in one call. - /// Useful when switching sessions to ensure prior history remains visible. - pub(crate) fn render_transcript_once(&mut self, tui: &mut tui::Tui) { - if !self.transcript_cells.is_empty() { - let width = tui.terminal.last_known_screen_size.width; - for cell in &self.transcript_cells { - tui.insert_history_lines(cell.display_lines(width)); - } - } - } - - /// Initialize backtrack state and show composer hint. - fn prime_backtrack(&mut self) { - self.backtrack.primed = true; - self.backtrack.nth_user_message = usize::MAX; - self.backtrack.base_id = self.chat_widget.conversation_id(); - self.chat_widget.show_esc_backtrack_hint(); - } - - /// Open overlay and begin backtrack preview flow (first step + highlight). - fn open_backtrack_preview(&mut self, tui: &mut tui::Tui) { - self.open_transcript_overlay(tui); - self.backtrack.overlay_preview_active = true; - // Composer is hidden by overlay; clear its hint. - self.chat_widget.clear_esc_backtrack_hint(); - self.step_backtrack_and_highlight(tui); - } - - /// When overlay is already open, begin preview mode and select latest user message. - fn begin_overlay_backtrack_preview(&mut self, tui: &mut tui::Tui) { - self.backtrack.primed = true; - self.backtrack.base_id = self.chat_widget.conversation_id(); - self.backtrack.overlay_preview_active = true; - let count = user_count(&self.transcript_cells); - if let Some(last) = count.checked_sub(1) { - self.apply_backtrack_selection_internal(last); - } - tui.frame_requester().schedule_frame(); - } - - /// Step selection to the next older user message and update overlay. - fn step_backtrack_and_highlight(&mut self, tui: &mut tui::Tui) { - let count = user_count(&self.transcript_cells); - if count == 0 { - return; - } - - let last_index = count.saturating_sub(1); - let next_selection = if self.backtrack.nth_user_message == usize::MAX { - last_index - } else if self.backtrack.nth_user_message == 0 { - 0 - } else { - self.backtrack - .nth_user_message - .saturating_sub(1) - .min(last_index) - }; - - self.apply_backtrack_selection_internal(next_selection); - tui.frame_requester().schedule_frame(); - } - - /// Step selection to the next newer user message and update overlay. - fn step_forward_backtrack_and_highlight(&mut self, tui: &mut tui::Tui) { - let count = user_count(&self.transcript_cells); - if count == 0 { - return; - } - - let last_index = count.saturating_sub(1); - let next_selection = if self.backtrack.nth_user_message == usize::MAX { - last_index - } else { - self.backtrack - .nth_user_message - .saturating_add(1) - .min(last_index) - }; - - self.apply_backtrack_selection_internal(next_selection); - tui.frame_requester().schedule_frame(); - } - - /// Apply a computed backtrack selection to the overlay and internal counter. - fn apply_backtrack_selection_internal(&mut self, nth_user_message: usize) { - if let Some(cell_idx) = nth_user_position(&self.transcript_cells, nth_user_message) { - self.backtrack.nth_user_message = nth_user_message; - if let Some(Overlay::Transcript(t)) = &mut self.overlay { - t.set_highlight_cell(Some(cell_idx)); - } - } else { - self.backtrack.nth_user_message = usize::MAX; - if let Some(Overlay::Transcript(t)) = &mut self.overlay { - t.set_highlight_cell(None); - } - } - } - - /// Forward any event to the overlay and close it if done. - fn overlay_forward_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> { - // Transcript overlay draws are special: include a live, in-flight tail so the - // overlay matches the main viewport while the active cell is still streaming. - // This path also drives tail animations and closes the overlay immediately - // once it reports completion. - if let TuiEvent::Draw = &event - && let Some(Overlay::Transcript(t)) = &mut self.overlay - { - let active_key = self.chat_widget.active_cell_transcript_key(); - let chat_widget = &self.chat_widget; - tui.draw(u16::MAX, |frame| { - let width = frame.area().width.max(1); - t.sync_live_tail(width, active_key, |w| { - chat_widget.active_cell_transcript_lines(w) - }); - t.render(frame.area(), frame.buffer); - })?; - let close_overlay = t.is_done(); - if !close_overlay - && active_key.is_some_and(|key| key.animation_tick.is_some()) - && t.is_scrolled_to_bottom() - { - tui.frame_requester() - .schedule_frame_in(std::time::Duration::from_millis(50)); - } - if close_overlay { - self.close_transcript_overlay(tui); - tui.frame_requester().schedule_frame(); - } - return Ok(()); - } - - if let Some(overlay) = &mut self.overlay { - overlay.handle_event(tui, event)?; - if overlay.is_done() { - self.close_transcript_overlay(tui); - tui.frame_requester().schedule_frame(); - } - } - Ok(()) - } - - /// Handle Enter in overlay backtrack preview: confirm selection and reset state. - fn overlay_confirm_backtrack(&mut self, tui: &mut tui::Tui) { - let nth_user_message = self.backtrack.nth_user_message; - let selection = self.backtrack_selection(nth_user_message); - self.close_transcript_overlay(tui); - if let Some(selection) = selection { - self.apply_backtrack_rollback(selection); - tui.frame_requester().schedule_frame(); - } - } - - /// Handle Esc in overlay backtrack preview: step selection if armed, else forward. - fn overlay_step_backtrack(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> { - if self.backtrack.base_id.is_some() { - self.step_backtrack_and_highlight(tui); - } else { - self.overlay_forward_event(tui, event)?; - } - Ok(()) - } - - /// Handle Right in overlay backtrack preview: step selection forward if armed, else forward. - fn overlay_step_backtrack_forward( - &mut self, - tui: &mut tui::Tui, - event: TuiEvent, - ) -> Result<()> { - if self.backtrack.base_id.is_some() { - self.step_forward_backtrack_and_highlight(tui); - } else { - self.overlay_forward_event(tui, event)?; - } - Ok(()) - } - - /// Confirm a primed backtrack from the main view (no overlay visible). - /// Computes the prefill from the selected user message for rollback. - pub(crate) fn confirm_backtrack_from_main(&mut self) -> Option { - let selection = self.backtrack_selection(self.backtrack.nth_user_message); - self.reset_backtrack_state(); - selection - } - - /// Clear all backtrack-related state and composer hints. - pub(crate) fn reset_backtrack_state(&mut self) { - self.backtrack.primed = false; - self.backtrack.base_id = None; - self.backtrack.nth_user_message = usize::MAX; - // In case a hint is somehow still visible (e.g., race with overlay open/close). - self.chat_widget.clear_esc_backtrack_hint(); - } - - pub(crate) fn apply_backtrack_selection( - &mut self, - tui: &mut tui::Tui, - selection: BacktrackSelection, - ) { - self.apply_backtrack_rollback(selection); - tui.frame_requester().schedule_frame(); - } - - pub(crate) fn handle_backtrack_event(&mut self, event: &EventMsg) { - match event { - EventMsg::ThreadRolledBack(_) => self.finish_pending_backtrack(), - EventMsg::Error(ErrorEvent { - codex_error_info: Some(CodexErrorInfo::ThreadRollbackFailed), - .. - }) => { - // Core rejected the rollback; clear the guard so the user can retry. - self.backtrack.pending_rollback = None; - } - _ => {} - } - } - - /// Finish a pending rollback by applying the local trim and scheduling a scrollback refresh. - /// - /// We ignore events that do not correspond to the currently active conversation to avoid - /// applying stale updates after a session switch. - fn finish_pending_backtrack(&mut self) { - let Some(pending) = self.backtrack.pending_rollback.take() else { - return; - }; - if pending.thread_id != self.chat_widget.conversation_id() { - // Ignore rollbacks targeting a prior thread. - return; - } - self.trim_transcript_for_backtrack(pending.selection.nth_user_message); - self.backtrack_render_pending = true; - } - fn backtrack_selection(&self, nth_user_message: usize) -> Option { - let base_id = self.backtrack.base_id?; - if self.chat_widget.conversation_id() != Some(base_id) { - return None; - } - - let prefill = nth_user_position(&self.transcript_cells, nth_user_message) - .and_then(|idx| self.transcript_cells.get(idx)) - .and_then(|cell| cell.as_any().downcast_ref::()) - .map(|c| c.message.clone()) - .unwrap_or_default(); - - Some(BacktrackSelection { - nth_user_message, - prefill, - }) - } - - /// Trim `transcript_cells` to preserve only content before the selected user message. - fn trim_transcript_for_backtrack(&mut self, nth_user_message: usize) { - trim_transcript_cells_to_nth_user(&mut self.transcript_cells, nth_user_message); - } -} - -fn trim_transcript_cells_to_nth_user( - transcript_cells: &mut Vec>, - nth_user_message: usize, -) { - if nth_user_message == usize::MAX { - return; - } - - if let Some(cut_idx) = nth_user_position(transcript_cells, nth_user_message) { - transcript_cells.truncate(cut_idx); - } -} - -pub(crate) fn user_count(cells: &[Arc]) -> usize { - user_positions_iter(cells).count() -} - -fn nth_user_position( - cells: &[Arc], - nth: usize, -) -> Option { - user_positions_iter(cells) - .enumerate() - .find_map(|(i, idx)| (i == nth).then_some(idx)) -} - -fn user_positions_iter( - cells: &[Arc], -) -> impl Iterator + '_ { - let session_start_type = TypeId::of::(); - let user_type = TypeId::of::(); - let type_of = |cell: &Arc| cell.as_any().type_id(); - - let start = cells - .iter() - .rposition(|cell| type_of(cell) == session_start_type) - .map_or(0, |idx| idx + 1); - - cells - .iter() - .enumerate() - .skip(start) - .filter_map(move |(idx, cell)| (type_of(cell) == user_type).then_some(idx)) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::history_cell::AgentMessageCell; - use crate::history_cell::HistoryCell; - use ratatui::prelude::Line; - use std::sync::Arc; - - #[test] - fn trim_transcript_for_first_user_drops_user_and_newer_cells() { - let mut cells: Vec> = vec![ - Arc::new(UserHistoryCell { - message: "first user".to_string(), - text_elements: Vec::new(), - local_image_paths: Vec::new(), - }) as Arc, - Arc::new(AgentMessageCell::new(vec![Line::from("assistant")], true)) - as Arc, - ]; - trim_transcript_cells_to_nth_user(&mut cells, 0); - - assert!(cells.is_empty()); - } - - #[test] - fn trim_transcript_preserves_cells_before_selected_user() { - let mut cells: Vec> = vec![ - Arc::new(AgentMessageCell::new(vec![Line::from("intro")], true)) - as Arc, - Arc::new(UserHistoryCell { - message: "first".to_string(), - text_elements: Vec::new(), - local_image_paths: Vec::new(), - }) as Arc, - Arc::new(AgentMessageCell::new(vec![Line::from("after")], false)) - as Arc, - ]; - trim_transcript_cells_to_nth_user(&mut cells, 0); - - assert_eq!(cells.len(), 1); - let agent = cells[0] - .as_any() - .downcast_ref::() - .expect("agent cell"); - let agent_lines = agent.display_lines(u16::MAX); - assert_eq!(agent_lines.len(), 1); - let intro_text: String = agent_lines[0] - .spans - .iter() - .map(|span| span.content.as_ref()) - .collect(); - assert_eq!(intro_text, "• intro"); - } - - #[test] - fn trim_transcript_for_later_user_keeps_prior_history() { - let mut cells: Vec> = vec![ - Arc::new(AgentMessageCell::new(vec![Line::from("intro")], true)) - as Arc, - Arc::new(UserHistoryCell { - message: "first".to_string(), - text_elements: Vec::new(), - local_image_paths: Vec::new(), - }) as Arc, - Arc::new(AgentMessageCell::new(vec![Line::from("between")], false)) - as Arc, - Arc::new(UserHistoryCell { - message: "second".to_string(), - text_elements: Vec::new(), - local_image_paths: Vec::new(), - }) as Arc, - Arc::new(AgentMessageCell::new(vec![Line::from("tail")], false)) - as Arc, - ]; - trim_transcript_cells_to_nth_user(&mut cells, 1); - - assert_eq!(cells.len(), 3); - let agent_intro = cells[0] - .as_any() - .downcast_ref::() - .expect("intro agent"); - let intro_lines = agent_intro.display_lines(u16::MAX); - let intro_text: String = intro_lines[0] - .spans - .iter() - .map(|span| span.content.as_ref()) - .collect(); - assert_eq!(intro_text, "• intro"); - - let user_first = cells[1] - .as_any() - .downcast_ref::() - .expect("first user"); - assert_eq!(user_first.message, "first"); - - let agent_between = cells[2] - .as_any() - .downcast_ref::() - .expect("between agent"); - let between_lines = agent_between.display_lines(u16::MAX); - let between_text: String = between_lines[0] - .spans - .iter() - .map(|span| span.content.as_ref()) - .collect(); - assert_eq!(between_text, " between"); - } -} diff --git a/codex-rs/tui2/src/app_event.rs b/codex-rs/tui2/src/app_event.rs deleted file mode 100644 index f4640968da9..00000000000 --- a/codex-rs/tui2/src/app_event.rs +++ /dev/null @@ -1,256 +0,0 @@ -//! Application-level events used to coordinate UI actions. -//! -//! `AppEvent` is the internal message bus between UI components and the top-level `App` loop. -//! Widgets emit events to request actions that must be handled at the app layer (like opening -//! pickers, persisting configuration, or shutting down the agent), without needing direct access to -//! `App` internals. -//! -//! Exit is modelled explicitly via `AppEvent::Exit(ExitMode)` so callers can request shutdown-first -//! quits without reaching into the app loop or coupling to shutdown/exit sequencing. - -use std::path::PathBuf; - -use codex_common::approval_presets::ApprovalPreset; -use codex_core::features::Feature; -use codex_core::protocol::Event; -use codex_core::protocol::RateLimitSnapshot; -use codex_file_search::FileMatch; -use codex_protocol::openai_models::ModelPreset; - -use crate::bottom_pane::ApprovalRequest; -use crate::history_cell::HistoryCell; - -use codex_core::protocol::AskForApproval; -use codex_core::protocol::SandboxPolicy; -use codex_protocol::config_types::CollaborationMode; -use codex_protocol::openai_models::ReasoningEffort; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[cfg_attr(not(target_os = "windows"), allow(dead_code))] -pub(crate) enum WindowsSandboxEnableMode { - Elevated, - Legacy, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[cfg_attr(not(target_os = "windows"), allow(dead_code))] -pub(crate) enum WindowsSandboxFallbackReason { - ElevationFailed, -} - -#[allow(clippy::large_enum_variant)] -#[derive(Debug)] -pub(crate) enum AppEvent { - CodexEvent(Event), - - /// Start a new session. - NewSession, - - /// Open the resume picker inside the running TUI session. - OpenResumePicker, - - /// Fork the current session into a new thread. - ForkCurrentSession, - - /// Request to exit the application. - /// - /// Use `ShutdownFirst` for user-initiated quits so core cleanup runs and the - /// UI exits only after `ShutdownComplete`. `Immediate` is a last-resort - /// escape hatch that skips shutdown and may drop in-flight work (e.g., - /// background tasks, rollout flush, or child process cleanup). - Exit(ExitMode), - - /// Request to exit the application due to a fatal error. - FatalExitRequest(String), - - /// Forward an `Op` to the Agent. Using an `AppEvent` for this avoids - /// bubbling channels through layers of widgets. - CodexOp(codex_core::protocol::Op), - - /// Kick off an asynchronous file search for the given query (text after - /// the `@`). Previous searches may be cancelled by the app layer so there - /// is at most one in-flight search. - StartFileSearch(String), - - /// Result of a completed asynchronous file search. The `query` echoes the - /// original search term so the UI can decide whether the results are - /// still relevant. - FileSearchResult { - query: String, - matches: Vec, - }, - - /// Result of refreshing rate limits - RateLimitSnapshotFetched(RateLimitSnapshot), - - /// Result of computing a `/diff` command. - DiffResult(String), - - InsertHistoryCell(Box), - - StartCommitAnimation, - StopCommitAnimation, - CommitTick, - - /// Update the current reasoning effort in the running app and widget. - UpdateReasoningEffort(Option), - - /// Update the current model slug in the running app and widget. - UpdateModel(String), - - /// Update the current collaboration mode in the running app and widget. - UpdateCollaborationMode(CollaborationMode), - - /// Persist the selected model and reasoning effort to the appropriate config. - PersistModelSelection { - model: String, - effort: Option, - }, - - /// Open the reasoning selection popup after picking a model. - OpenReasoningPopup { - model: ModelPreset, - }, - - /// Open the full model picker (non-auto models). - OpenAllModelsPopup { - models: Vec, - }, - - /// Open the confirmation prompt before enabling full access mode. - OpenFullAccessConfirmation { - preset: ApprovalPreset, - }, - - /// Open the Windows world-writable directories warning. - /// If `preset` is `Some`, the confirmation will apply the provided - /// approval/sandbox configuration on Continue; if `None`, it performs no - /// policy change and only acknowledges/dismisses the warning. - #[cfg_attr(not(target_os = "windows"), allow(dead_code))] - OpenWorldWritableWarningConfirmation { - preset: Option, - /// Up to 3 sample world-writable directories to display in the warning. - sample_paths: Vec, - /// If there are more than `sample_paths`, this carries the remaining count. - extra_count: usize, - /// True when the scan failed (e.g. ACL query error) and protections could not be verified. - failed_scan: bool, - }, - - /// Prompt to enable the Windows sandbox feature before using Agent mode. - #[cfg_attr(not(target_os = "windows"), allow(dead_code))] - OpenWindowsSandboxEnablePrompt { - preset: ApprovalPreset, - }, - - /// Open the Windows sandbox fallback prompt after declining or failing elevation. - #[cfg_attr(not(target_os = "windows"), allow(dead_code))] - OpenWindowsSandboxFallbackPrompt { - preset: ApprovalPreset, - reason: WindowsSandboxFallbackReason, - }, - - /// Begin the elevated Windows sandbox setup flow. - #[cfg_attr(not(target_os = "windows"), allow(dead_code))] - BeginWindowsSandboxElevatedSetup { - preset: ApprovalPreset, - }, - - /// Enable the Windows sandbox feature and switch to Agent mode. - #[cfg_attr(not(target_os = "windows"), allow(dead_code))] - EnableWindowsSandboxForAgentMode { - preset: ApprovalPreset, - mode: WindowsSandboxEnableMode, - }, - - /// Update the current approval policy in the running app and widget. - UpdateAskForApprovalPolicy(AskForApproval), - - /// Update the current sandbox policy in the running app and widget. - UpdateSandboxPolicy(SandboxPolicy), - - /// Update feature flags and persist them to config. - UpdateFeatureFlags { - updates: Vec<(Feature, bool)>, - }, - - /// Update whether the full access warning prompt has been acknowledged. - UpdateFullAccessWarningAcknowledged(bool), - - /// Update whether the world-writable directories warning has been acknowledged. - #[cfg_attr(not(target_os = "windows"), allow(dead_code))] - UpdateWorldWritableWarningAcknowledged(bool), - - /// Update whether the rate limit switch prompt has been acknowledged for the session. - UpdateRateLimitSwitchPromptHidden(bool), - - /// Persist the acknowledgement flag for the full access warning prompt. - PersistFullAccessWarningAcknowledged, - - /// Persist the acknowledgement flag for the world-writable directories warning. - #[cfg_attr(not(target_os = "windows"), allow(dead_code))] - PersistWorldWritableWarningAcknowledged, - - /// Persist the acknowledgement flag for the rate limit switch prompt. - PersistRateLimitSwitchPromptHidden, - - /// Persist the acknowledgement flag for the model migration prompt. - PersistModelMigrationPromptAcknowledged { - from_model: String, - to_model: String, - }, - - /// Skip the next world-writable scan (one-shot) after a user-confirmed continue. - #[cfg_attr(not(target_os = "windows"), allow(dead_code))] - SkipNextWorldWritableScan, - - /// Re-open the approval presets popup. - OpenApprovalsPopup, - - /// Open the branch picker option from the review popup. - OpenReviewBranchPicker(PathBuf), - - /// Open the commit picker option from the review popup. - OpenReviewCommitPicker(PathBuf), - - /// Open the custom prompt option from the review popup. - OpenReviewCustomPrompt, - - /// Open the approval popup. - FullScreenApprovalRequest(ApprovalRequest), - - /// Open the feedback note entry overlay after the user selects a category. - OpenFeedbackNote { - category: FeedbackCategory, - include_logs: bool, - }, - - /// Open the upload consent popup for feedback after selecting a category. - OpenFeedbackConsent { - category: FeedbackCategory, - }, -} - -/// The exit strategy requested by the UI layer. -/// -/// Most user-initiated exits should use `ShutdownFirst` so core cleanup runs and the UI exits only -/// after core acknowledges completion. `Immediate` is an escape hatch for cases where shutdown has -/// already completed (or is being bypassed) and the UI loop should terminate right away. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum ExitMode { - /// Shutdown core and exit after completion. - ShutdownFirst, - /// Exit the UI loop immediately without waiting for shutdown. - /// - /// This skips `Op::Shutdown`, so any in-flight work may be dropped and - /// cleanup that normally runs before `ShutdownComplete` can be missed. - Immediate, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum FeedbackCategory { - BadResult, - GoodResult, - Bug, - Other, -} diff --git a/codex-rs/tui2/src/app_event_sender.rs b/codex-rs/tui2/src/app_event_sender.rs deleted file mode 100644 index c1427b3ff02..00000000000 --- a/codex-rs/tui2/src/app_event_sender.rs +++ /dev/null @@ -1,28 +0,0 @@ -use tokio::sync::mpsc::UnboundedSender; - -use crate::app_event::AppEvent; -use crate::session_log; - -#[derive(Clone, Debug)] -pub(crate) struct AppEventSender { - pub app_event_tx: UnboundedSender, -} - -impl AppEventSender { - pub(crate) fn new(app_event_tx: UnboundedSender) -> Self { - Self { app_event_tx } - } - - /// Send an event to the app event channel. If it fails, we swallow the - /// error and log it. - pub(crate) fn send(&self, event: AppEvent) { - // Record inbound events for high-fidelity session replay. - // Avoid double-logging Ops; those are logged at the point of submission. - if !matches!(event, AppEvent::CodexOp(_)) { - session_log::log_inbound_app_event(&event); - } - if let Err(e) = self.app_event_tx.send(event) { - tracing::error!("failed to send event: {e}"); - } - } -} diff --git a/codex-rs/tui2/src/ascii_animation.rs b/codex-rs/tui2/src/ascii_animation.rs deleted file mode 100644 index b2d9fc1d196..00000000000 --- a/codex-rs/tui2/src/ascii_animation.rs +++ /dev/null @@ -1,111 +0,0 @@ -use std::convert::TryFrom; -use std::time::Duration; -use std::time::Instant; - -use rand::Rng as _; - -use crate::frames::ALL_VARIANTS; -use crate::frames::FRAME_TICK_DEFAULT; -use crate::tui::FrameRequester; - -/// Drives ASCII art animations shared across popups and onboarding widgets. -pub(crate) struct AsciiAnimation { - request_frame: FrameRequester, - variants: &'static [&'static [&'static str]], - variant_idx: usize, - frame_tick: Duration, - start: Instant, -} - -impl AsciiAnimation { - pub(crate) fn new(request_frame: FrameRequester) -> Self { - Self::with_variants(request_frame, ALL_VARIANTS, 0) - } - - pub(crate) fn with_variants( - request_frame: FrameRequester, - variants: &'static [&'static [&'static str]], - variant_idx: usize, - ) -> Self { - assert!( - !variants.is_empty(), - "AsciiAnimation requires at least one animation variant", - ); - let clamped_idx = variant_idx.min(variants.len() - 1); - Self { - request_frame, - variants, - variant_idx: clamped_idx, - frame_tick: FRAME_TICK_DEFAULT, - start: Instant::now(), - } - } - - pub(crate) fn schedule_next_frame(&self) { - let tick_ms = self.frame_tick.as_millis(); - if tick_ms == 0 { - self.request_frame.schedule_frame(); - return; - } - let elapsed_ms = self.start.elapsed().as_millis(); - let rem_ms = elapsed_ms % tick_ms; - let delay_ms = if rem_ms == 0 { - tick_ms - } else { - tick_ms - rem_ms - }; - if let Ok(delay_ms_u64) = u64::try_from(delay_ms) { - self.request_frame - .schedule_frame_in(Duration::from_millis(delay_ms_u64)); - } else { - self.request_frame.schedule_frame(); - } - } - - pub(crate) fn current_frame(&self) -> &'static str { - let frames = self.frames(); - if frames.is_empty() { - return ""; - } - let tick_ms = self.frame_tick.as_millis(); - if tick_ms == 0 { - return frames[0]; - } - let elapsed_ms = self.start.elapsed().as_millis(); - let idx = ((elapsed_ms / tick_ms) % frames.len() as u128) as usize; - frames[idx] - } - - pub(crate) fn pick_random_variant(&mut self) -> bool { - if self.variants.len() <= 1 { - return false; - } - let mut rng = rand::rng(); - let mut next = self.variant_idx; - while next == self.variant_idx { - next = rng.random_range(0..self.variants.len()); - } - self.variant_idx = next; - self.request_frame.schedule_frame(); - true - } - - #[allow(dead_code)] - pub(crate) fn request_frame(&self) { - self.request_frame.schedule_frame(); - } - - fn frames(&self) -> &'static [&'static str] { - self.variants[self.variant_idx] - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn frame_tick_must_be_nonzero() { - assert!(FRAME_TICK_DEFAULT.as_millis() > 0); - } -} diff --git a/codex-rs/tui2/src/bin/md-events2.rs b/codex-rs/tui2/src/bin/md-events2.rs deleted file mode 100644 index f1117fad91d..00000000000 --- a/codex-rs/tui2/src/bin/md-events2.rs +++ /dev/null @@ -1,15 +0,0 @@ -use std::io::Read; -use std::io::{self}; - -fn main() { - let mut input = String::new(); - if let Err(err) = io::stdin().read_to_string(&mut input) { - eprintln!("failed to read stdin: {err}"); - std::process::exit(1); - } - - let parser = pulldown_cmark::Parser::new(&input); - for event in parser { - println!("{event:?}"); - } -} diff --git a/codex-rs/tui2/src/bottom_pane/AGENTS.md b/codex-rs/tui2/src/bottom_pane/AGENTS.md deleted file mode 100644 index 44fac8e50af..00000000000 --- a/codex-rs/tui2/src/bottom_pane/AGENTS.md +++ /dev/null @@ -1,15 +0,0 @@ -# TUI2 bottom pane (state machines) - -When changing the paste-burst or chat-composer state machines in this folder, keep the docs in sync: - -- Update the relevant module docs (`chat_composer.rs` and/or `paste_burst.rs`) so they remain a - readable, top-down explanation of the current behavior. -- Update the narrative doc `docs/tui-chat-composer.md` whenever behavior/assumptions change (Enter - handling, retro-capture, flush/clear rules, `disable_paste_burst`, non-ASCII/IME handling). -- Keep `codex-rs/tui` and `codex-rs/tui2` implementations/docstrings aligned unless the divergence - is intentional and documented. - -Practical check: - -- After edits, sanity-check that docs mention only APIs/behavior that exist in code (especially the - Enter/newline paths and `disable_paste_burst` semantics). diff --git a/codex-rs/tui2/src/bottom_pane/approval_overlay.rs b/codex-rs/tui2/src/bottom_pane/approval_overlay.rs deleted file mode 100644 index 0f0445fee83..00000000000 --- a/codex-rs/tui2/src/bottom_pane/approval_overlay.rs +++ /dev/null @@ -1,727 +0,0 @@ -use std::collections::HashMap; -use std::path::PathBuf; - -use crate::app_event::AppEvent; -use crate::app_event_sender::AppEventSender; -use crate::bottom_pane::BottomPaneView; -use crate::bottom_pane::CancellationEvent; -use crate::bottom_pane::list_selection_view::ListSelectionView; -use crate::bottom_pane::list_selection_view::SelectionItem; -use crate::bottom_pane::list_selection_view::SelectionViewParams; -use crate::diff_render::DiffSummary; -use crate::exec_command::strip_bash_lc_and_escape; -use crate::history_cell; -use crate::key_hint; -use crate::key_hint::KeyBinding; -use crate::render::highlight::highlight_bash_to_lines; -use crate::render::renderable::ColumnRenderable; -use crate::render::renderable::Renderable; -use codex_core::features::Feature; -use codex_core::features::Features; -use codex_core::protocol::ElicitationAction; -use codex_core::protocol::ExecPolicyAmendment; -use codex_core::protocol::FileChange; -use codex_core::protocol::Op; -use codex_core::protocol::ReviewDecision; -use crossterm::event::KeyCode; -use crossterm::event::KeyEvent; -use crossterm::event::KeyEventKind; -use crossterm::event::KeyModifiers; -use mcp_types::RequestId; -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -use ratatui::style::Stylize; -use ratatui::text::Line; -use ratatui::text::Span; -use ratatui::widgets::Paragraph; -use ratatui::widgets::Wrap; - -/// Request coming from the agent that needs user approval. -#[derive(Clone, Debug)] -pub(crate) enum ApprovalRequest { - Exec { - id: String, - command: Vec, - reason: Option, - proposed_execpolicy_amendment: Option, - }, - ApplyPatch { - id: String, - reason: Option, - cwd: PathBuf, - changes: HashMap, - }, - McpElicitation { - server_name: String, - request_id: RequestId, - message: String, - }, -} - -/// Modal overlay asking the user to approve or deny one or more requests. -pub(crate) struct ApprovalOverlay { - current_request: Option, - current_variant: Option, - queue: Vec, - app_event_tx: AppEventSender, - list: ListSelectionView, - options: Vec, - current_complete: bool, - done: bool, - features: Features, -} - -impl ApprovalOverlay { - pub fn new(request: ApprovalRequest, app_event_tx: AppEventSender, features: Features) -> Self { - let mut view = Self { - current_request: None, - current_variant: None, - queue: Vec::new(), - app_event_tx: app_event_tx.clone(), - list: ListSelectionView::new(Default::default(), app_event_tx), - options: Vec::new(), - current_complete: false, - done: false, - features, - }; - view.set_current(request); - view - } - - pub fn enqueue_request(&mut self, req: ApprovalRequest) { - self.queue.push(req); - } - - fn set_current(&mut self, request: ApprovalRequest) { - self.current_request = Some(request.clone()); - let ApprovalRequestState { variant, header } = ApprovalRequestState::from(request); - self.current_variant = Some(variant.clone()); - self.current_complete = false; - let (options, params) = Self::build_options(variant, header, &self.features); - self.options = options; - self.list = ListSelectionView::new(params, self.app_event_tx.clone()); - } - - fn build_options( - variant: ApprovalVariant, - header: Box, - features: &Features, - ) -> (Vec, SelectionViewParams) { - let (options, title) = match &variant { - ApprovalVariant::Exec { - proposed_execpolicy_amendment, - .. - } => ( - exec_options(proposed_execpolicy_amendment.clone(), features), - "Would you like to run the following command?".to_string(), - ), - ApprovalVariant::ApplyPatch { .. } => ( - patch_options(), - "Would you like to make the following edits?".to_string(), - ), - ApprovalVariant::McpElicitation { server_name, .. } => ( - elicitation_options(), - format!("{server_name} needs your approval."), - ), - }; - - let header = Box::new(ColumnRenderable::with([ - Line::from(title.bold()).into(), - Line::from("").into(), - header, - ])); - - let items = options - .iter() - .map(|opt| SelectionItem { - name: opt.label.clone(), - display_shortcut: opt - .display_shortcut - .or_else(|| opt.additional_shortcuts.first().copied()), - dismiss_on_select: false, - ..Default::default() - }) - .collect(); - - let params = SelectionViewParams { - footer_hint: Some(Line::from(vec![ - "Press ".into(), - key_hint::plain(KeyCode::Enter).into(), - " to confirm or ".into(), - key_hint::plain(KeyCode::Esc).into(), - " to cancel".into(), - ])), - items, - header, - ..Default::default() - }; - - (options, params) - } - - fn apply_selection(&mut self, actual_idx: usize) { - if self.current_complete { - return; - } - let Some(option) = self.options.get(actual_idx) else { - return; - }; - if let Some(variant) = self.current_variant.as_ref() { - match (variant, &option.decision) { - (ApprovalVariant::Exec { id, command, .. }, ApprovalDecision::Review(decision)) => { - self.handle_exec_decision(id, command, decision.clone()); - } - (ApprovalVariant::ApplyPatch { id, .. }, ApprovalDecision::Review(decision)) => { - self.handle_patch_decision(id, decision.clone()); - } - ( - ApprovalVariant::McpElicitation { - server_name, - request_id, - }, - ApprovalDecision::McpElicitation(decision), - ) => { - self.handle_elicitation_decision(server_name, request_id, *decision); - } - _ => {} - } - } - - self.current_complete = true; - self.advance_queue(); - } - - fn handle_exec_decision(&self, id: &str, command: &[String], decision: ReviewDecision) { - let cell = history_cell::new_approval_decision_cell(command.to_vec(), decision.clone()); - self.app_event_tx.send(AppEvent::InsertHistoryCell(cell)); - self.app_event_tx.send(AppEvent::CodexOp(Op::ExecApproval { - id: id.to_string(), - decision, - })); - } - - fn handle_patch_decision(&self, id: &str, decision: ReviewDecision) { - self.app_event_tx.send(AppEvent::CodexOp(Op::PatchApproval { - id: id.to_string(), - decision, - })); - } - - fn handle_elicitation_decision( - &self, - server_name: &str, - request_id: &RequestId, - decision: ElicitationAction, - ) { - self.app_event_tx - .send(AppEvent::CodexOp(Op::ResolveElicitation { - server_name: server_name.to_string(), - request_id: request_id.clone(), - decision, - })); - } - - fn advance_queue(&mut self) { - if let Some(next) = self.queue.pop() { - self.set_current(next); - } else { - self.done = true; - } - } - - fn try_handle_shortcut(&mut self, key_event: &KeyEvent) -> bool { - match key_event { - KeyEvent { - kind: KeyEventKind::Press, - code: KeyCode::Char('a'), - modifiers, - .. - } if modifiers.contains(KeyModifiers::CONTROL) => { - if let Some(request) = self.current_request.as_ref() { - self.app_event_tx - .send(AppEvent::FullScreenApprovalRequest(request.clone())); - true - } else { - false - } - } - e => { - if let Some(idx) = self - .options - .iter() - .position(|opt| opt.shortcuts().any(|s| s.is_press(*e))) - { - self.apply_selection(idx); - true - } else { - false - } - } - } - } -} - -impl BottomPaneView for ApprovalOverlay { - fn handle_key_event(&mut self, key_event: KeyEvent) { - if self.try_handle_shortcut(&key_event) { - return; - } - self.list.handle_key_event(key_event); - if let Some(idx) = self.list.take_last_selected_index() { - self.apply_selection(idx); - } - } - - fn on_ctrl_c(&mut self) -> CancellationEvent { - if self.done { - return CancellationEvent::Handled; - } - if !self.current_complete - && let Some(variant) = self.current_variant.as_ref() - { - match &variant { - ApprovalVariant::Exec { id, command, .. } => { - self.handle_exec_decision(id, command, ReviewDecision::Abort); - } - ApprovalVariant::ApplyPatch { id, .. } => { - self.handle_patch_decision(id, ReviewDecision::Abort); - } - ApprovalVariant::McpElicitation { - server_name, - request_id, - } => { - self.handle_elicitation_decision( - server_name, - request_id, - ElicitationAction::Cancel, - ); - } - } - } - self.queue.clear(); - self.done = true; - CancellationEvent::Handled - } - - fn is_complete(&self) -> bool { - self.done - } - - fn try_consume_approval_request( - &mut self, - request: ApprovalRequest, - ) -> Option { - self.enqueue_request(request); - None - } -} - -impl Renderable for ApprovalOverlay { - fn desired_height(&self, width: u16) -> u16 { - self.list.desired_height(width) - } - - fn render(&self, area: Rect, buf: &mut Buffer) { - self.list.render(area, buf); - } - - fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { - self.list.cursor_pos(area) - } -} - -struct ApprovalRequestState { - variant: ApprovalVariant, - header: Box, -} - -impl From for ApprovalRequestState { - fn from(value: ApprovalRequest) -> Self { - match value { - ApprovalRequest::Exec { - id, - command, - reason, - proposed_execpolicy_amendment, - } => { - let mut header: Vec> = Vec::new(); - if let Some(reason) = reason { - header.push(Line::from(vec!["Reason: ".into(), reason.italic()])); - header.push(Line::from("")); - } - let full_cmd = strip_bash_lc_and_escape(&command); - let mut full_cmd_lines = highlight_bash_to_lines(&full_cmd); - if let Some(first) = full_cmd_lines.first_mut() { - first.spans.insert(0, Span::from("$ ")); - } - header.extend(full_cmd_lines); - Self { - variant: ApprovalVariant::Exec { - id, - command, - proposed_execpolicy_amendment, - }, - header: Box::new(Paragraph::new(header).wrap(Wrap { trim: false })), - } - } - ApprovalRequest::ApplyPatch { - id, - reason, - cwd, - changes, - } => { - let mut header: Vec> = Vec::new(); - if let Some(reason) = reason - && !reason.is_empty() - { - header.push(Box::new( - Paragraph::new(Line::from_iter(["Reason: ".into(), reason.italic()])) - .wrap(Wrap { trim: false }), - )); - header.push(Box::new(Line::from(""))); - } - header.push(DiffSummary::new(changes, cwd).into()); - Self { - variant: ApprovalVariant::ApplyPatch { id }, - header: Box::new(ColumnRenderable::with(header)), - } - } - ApprovalRequest::McpElicitation { - server_name, - request_id, - message, - } => { - let header = Paragraph::new(vec![ - Line::from(vec!["Server: ".into(), server_name.clone().bold()]), - Line::from(""), - Line::from(message), - ]) - .wrap(Wrap { trim: false }); - Self { - variant: ApprovalVariant::McpElicitation { - server_name, - request_id, - }, - header: Box::new(header), - } - } - } - } -} - -#[derive(Clone)] -enum ApprovalVariant { - Exec { - id: String, - command: Vec, - proposed_execpolicy_amendment: Option, - }, - ApplyPatch { - id: String, - }, - McpElicitation { - server_name: String, - request_id: RequestId, - }, -} - -#[derive(Clone)] -enum ApprovalDecision { - Review(ReviewDecision), - McpElicitation(ElicitationAction), -} - -#[derive(Clone)] -struct ApprovalOption { - label: String, - decision: ApprovalDecision, - display_shortcut: Option, - additional_shortcuts: Vec, -} - -impl ApprovalOption { - fn shortcuts(&self) -> impl Iterator + '_ { - self.display_shortcut - .into_iter() - .chain(self.additional_shortcuts.iter().copied()) - } -} - -fn exec_options( - proposed_execpolicy_amendment: Option, - features: &Features, -) -> Vec { - vec![ApprovalOption { - label: "Yes, proceed".to_string(), - decision: ApprovalDecision::Review(ReviewDecision::Approved), - display_shortcut: None, - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))], - }] - .into_iter() - .chain( - proposed_execpolicy_amendment - .filter(|_| features.enabled(Feature::ExecPolicy)) - .and_then(|prefix| { - let rendered_prefix = strip_bash_lc_and_escape(prefix.command()); - if rendered_prefix.contains('\n') || rendered_prefix.contains('\r') { - return None; - } - - Some(ApprovalOption { - label: format!( - "Yes, and don't ask again for commands that start with `{rendered_prefix}`" - ), - decision: ApprovalDecision::Review( - ReviewDecision::ApprovedExecpolicyAmendment { - proposed_execpolicy_amendment: prefix, - }, - ), - display_shortcut: None, - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('p'))], - }) - }), - ) - .chain([ApprovalOption { - label: "No, and tell Codex what to do differently".to_string(), - decision: ApprovalDecision::Review(ReviewDecision::Abort), - display_shortcut: Some(key_hint::plain(KeyCode::Esc)), - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))], - }]) - .collect() -} - -fn patch_options() -> Vec { - vec![ - ApprovalOption { - label: "Yes, proceed".to_string(), - decision: ApprovalDecision::Review(ReviewDecision::Approved), - display_shortcut: None, - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))], - }, - ApprovalOption { - label: "Yes, and don't ask again for these files".to_string(), - decision: ApprovalDecision::Review(ReviewDecision::ApprovedForSession), - display_shortcut: None, - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('a'))], - }, - ApprovalOption { - label: "No, and tell Codex what to do differently".to_string(), - decision: ApprovalDecision::Review(ReviewDecision::Abort), - display_shortcut: Some(key_hint::plain(KeyCode::Esc)), - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))], - }, - ] -} - -fn elicitation_options() -> Vec { - vec![ - ApprovalOption { - label: "Yes, provide the requested info".to_string(), - decision: ApprovalDecision::McpElicitation(ElicitationAction::Accept), - display_shortcut: None, - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))], - }, - ApprovalOption { - label: "No, but continue without it".to_string(), - decision: ApprovalDecision::McpElicitation(ElicitationAction::Decline), - display_shortcut: None, - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))], - }, - ApprovalOption { - label: "Cancel this request".to_string(), - decision: ApprovalDecision::McpElicitation(ElicitationAction::Cancel), - display_shortcut: Some(key_hint::plain(KeyCode::Esc)), - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('c'))], - }, - ] -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::app_event::AppEvent; - use pretty_assertions::assert_eq; - use tokio::sync::mpsc::unbounded_channel; - - fn make_exec_request() -> ApprovalRequest { - ApprovalRequest::Exec { - id: "test".to_string(), - command: vec!["echo".to_string(), "hi".to_string()], - reason: Some("reason".to_string()), - proposed_execpolicy_amendment: None, - } - } - - #[test] - fn ctrl_c_aborts_and_clears_queue() { - let (tx, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx); - let mut view = ApprovalOverlay::new(make_exec_request(), tx, Features::with_defaults()); - view.enqueue_request(make_exec_request()); - assert_eq!(CancellationEvent::Handled, view.on_ctrl_c()); - assert!(view.queue.is_empty()); - assert!(view.is_complete()); - } - - #[test] - fn shortcut_triggers_selection() { - let (tx, mut rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx); - let mut view = ApprovalOverlay::new(make_exec_request(), tx, Features::with_defaults()); - assert!(!view.is_complete()); - view.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); - // We expect at least one CodexOp message in the queue. - let mut saw_op = false; - while let Ok(ev) = rx.try_recv() { - if matches!(ev, AppEvent::CodexOp(_)) { - saw_op = true; - break; - } - } - assert!(saw_op, "expected approval decision to emit an op"); - } - - #[test] - fn exec_prefix_option_emits_execpolicy_amendment() { - let (tx, mut rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx); - let mut view = ApprovalOverlay::new( - ApprovalRequest::Exec { - id: "test".to_string(), - command: vec!["echo".to_string()], - reason: None, - proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ - "echo".to_string(), - ])), - }, - tx, - Features::with_defaults(), - ); - view.handle_key_event(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)); - let mut saw_op = false; - while let Ok(ev) = rx.try_recv() { - if let AppEvent::CodexOp(Op::ExecApproval { decision, .. }) = ev { - assert_eq!( - decision, - ReviewDecision::ApprovedExecpolicyAmendment { - proposed_execpolicy_amendment: ExecPolicyAmendment::new(vec![ - "echo".to_string() - ]) - } - ); - saw_op = true; - break; - } - } - assert!( - saw_op, - "expected approval decision to emit an op with command prefix" - ); - } - - #[test] - fn exec_prefix_option_hidden_when_execpolicy_disabled() { - let (tx, mut rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx); - let mut view = ApprovalOverlay::new( - ApprovalRequest::Exec { - id: "test".to_string(), - command: vec!["echo".to_string()], - reason: None, - proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ - "echo".to_string(), - ])), - }, - tx, - { - let mut features = Features::with_defaults(); - features.disable(Feature::ExecPolicy); - features - }, - ); - assert_eq!(view.options.len(), 2); - view.handle_key_event(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)); - assert!(!view.is_complete()); - assert!(rx.try_recv().is_err()); - } - - #[test] - fn header_includes_command_snippet() { - let (tx, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx); - let command = vec!["echo".into(), "hello".into(), "world".into()]; - let exec_request = ApprovalRequest::Exec { - id: "test".into(), - command, - reason: None, - proposed_execpolicy_amendment: None, - }; - - let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults()); - let mut buf = Buffer::empty(Rect::new(0, 0, 80, view.desired_height(80))); - view.render(Rect::new(0, 0, 80, view.desired_height(80)), &mut buf); - - let rendered: Vec = (0..buf.area.height) - .map(|row| { - (0..buf.area.width) - .map(|col| buf[(col, row)].symbol().to_string()) - .collect() - }) - .collect(); - assert!( - rendered - .iter() - .any(|line| line.contains("echo hello world")), - "expected header to include command snippet, got {rendered:?}" - ); - } - - #[test] - fn exec_history_cell_wraps_with_two_space_indent() { - let command = vec![ - "/bin/zsh".into(), - "-lc".into(), - "git add tui/src/render/mod.rs tui/src/render/renderable.rs".into(), - ]; - let cell = history_cell::new_approval_decision_cell(command, ReviewDecision::Approved); - let lines = cell.display_lines(28); - let rendered: Vec = lines - .iter() - .map(|line| { - line.spans - .iter() - .map(|span| span.content.as_ref()) - .collect::() - }) - .collect(); - let expected = vec![ - "✔ You approved codex to run".to_string(), - " git add tui/src/render/".to_string(), - " mod.rs tui/src/render/".to_string(), - " renderable.rs this time".to_string(), - ]; - assert_eq!(rendered, expected); - } - - #[test] - fn enter_sets_last_selected_index_without_dismissing() { - let (tx_raw, mut rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let mut view = ApprovalOverlay::new(make_exec_request(), tx, Features::with_defaults()); - view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - assert!( - view.is_complete(), - "exec approval should complete without queued requests" - ); - - let mut decision = None; - while let Ok(ev) = rx.try_recv() { - if let AppEvent::CodexOp(Op::ExecApproval { decision: d, .. }) = ev { - decision = Some(d); - break; - } - } - assert_eq!(decision, Some(ReviewDecision::Approved)); - } -} diff --git a/codex-rs/tui2/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui2/src/bottom_pane/bottom_pane_view.rs deleted file mode 100644 index 499801cbb09..00000000000 --- a/codex-rs/tui2/src/bottom_pane/bottom_pane_view.rs +++ /dev/null @@ -1,37 +0,0 @@ -use crate::bottom_pane::ApprovalRequest; -use crate::render::renderable::Renderable; -use crossterm::event::KeyEvent; - -use super::CancellationEvent; - -/// Trait implemented by every view that can be shown in the bottom pane. -pub(crate) trait BottomPaneView: Renderable { - /// Handle a key event while the view is active. A redraw is always - /// scheduled after this call. - fn handle_key_event(&mut self, _key_event: KeyEvent) {} - - /// Return `true` if the view has finished and should be removed. - fn is_complete(&self) -> bool { - false - } - - /// Handle Ctrl-C while this view is active. - fn on_ctrl_c(&mut self) -> CancellationEvent { - CancellationEvent::NotHandled - } - - /// Optional paste handler. Return true if the view modified its state and - /// needs a redraw. - fn handle_paste(&mut self, _pasted: String) -> bool { - false - } - - /// Try to handle approval request; return the original value if not - /// consumed. - fn try_consume_approval_request( - &mut self, - request: ApprovalRequest, - ) -> Option { - Some(request) - } -} diff --git a/codex-rs/tui2/src/bottom_pane/chat_composer.rs b/codex-rs/tui2/src/bottom_pane/chat_composer.rs deleted file mode 100644 index 8c84676aa3c..00000000000 --- a/codex-rs/tui2/src/bottom_pane/chat_composer.rs +++ /dev/null @@ -1,6017 +0,0 @@ -//! The chat composer is the bottom-pane text input state machine. -//! -//! It is responsible for: -//! -//! - Editing the input buffer (a `TextArea`), including placeholder "elements" for attachments. -//! - Routing keys to the active popup (slash commands, file search, skill mentions). -//! - Handling submit vs newline on Enter. -//! - Turning raw key streams into explicit paste operations on platforms where terminals -//! don't provide reliable bracketed paste (notably Windows). -//! -//! # Key Event Routing -//! -//! Most key handling goes through [`ChatComposer::handle_key_event`], which dispatches to a -//! popup-specific handler if a popup is visible and otherwise to -//! [`ChatComposer::handle_key_event_without_popup`]. After every handled key, we call -//! [`ChatComposer::sync_popups`] so UI state follows the latest buffer/cursor. -//! -//! # Submission and Prompt Expansion -//! -//! On submit/queue paths, the composer: -//! -//! - Expands pending paste placeholders so element ranges align with the final text. -//! - Trims whitespace and rebases text elements accordingly. -//! - Expands `/prompts:` custom prompts (named or numeric args), preserving text elements. -//! - Prunes attached images so only placeholders that survive expansion are sent. -//! -//! The numeric auto-submit path used by the slash popup performs the same pending-paste expansion -//! and attachment pruning, and clears pending paste state on success. -//! -//! # Non-bracketed Paste Bursts -//! -//! On some terminals (especially on Windows), pastes arrive as a rapid sequence of -//! `KeyCode::Char` and `KeyCode::Enter` key events instead of a single paste event. -//! -//! To avoid misinterpreting these bursts as real typing, we feed "plain" character events into -//! [`PasteBurst`](super::paste_burst::PasteBurst), which buffers bursts and later flushes them -//! through [`ChatComposer::handle_paste`]. -//! -//! The burst detector intentionally treats ASCII and non-ASCII differently: -//! -//! - ASCII: we briefly hold the first fast char (flicker suppression) until we know whether the -//! stream is paste-like. -//! - non-ASCII: we do not hold the first char (IME input would feel dropped), but we still allow -//! burst detection for actual paste streams. -//! -//! The burst detector can also be disabled (`disable_paste_burst`), which bypasses the state -//! machine and treats the key stream as normal typing. When toggling from enabled → disabled, the -//! composer flushes/clears any in-flight burst state so it cannot leak into subsequent input. -//! -//! For the detailed burst state machine, see `codex-rs/tui2/src/bottom_pane/paste_burst.rs`. -//! For a narrative overview of the combined state machine, see `docs/tui-chat-composer.md`. -//! -//! # PasteBurst Integration Points -//! -//! The burst detector is consulted in a few specific places: -//! -//! - [`ChatComposer::handle_input_basic`]: flushes any due burst first, then intercepts plain char -//! input to either buffer it or insert normally. -//! - [`ChatComposer::handle_non_ascii_char`]: handles the non-ASCII/IME path without holding the -//! first char, while still allowing paste detection via retro-capture. -//! - [`ChatComposer::flush_paste_burst_if_due`]/[`ChatComposer::handle_paste_burst_flush`]: called -//! from UI ticks to turn a pending burst into either an explicit paste (`handle_paste`) or a -//! normal typed character. -//! -//! # Input Disabled Mode -//! -//! The composer can be temporarily read-only (`input_enabled = false`). In that mode it ignores -//! edits and renders a placeholder prompt instead of the editable textarea. This is part of the -//! overall state machine, since it affects which transitions are even possible from a given UI -//! state. - -use crate::key_hint; -use crate::key_hint::KeyBinding; -use crate::key_hint::has_ctrl_or_alt; -use crate::transcript_copy_action::TranscriptCopyFeedback; -use crossterm::event::KeyCode; -use crossterm::event::KeyEvent; -use crossterm::event::KeyEventKind; -use crossterm::event::KeyModifiers; -use ratatui::buffer::Buffer; -use ratatui::layout::Constraint; -use ratatui::layout::Layout; -use ratatui::layout::Margin; -use ratatui::layout::Rect; -use ratatui::style::Stylize; -use ratatui::text::Line; -use ratatui::text::Span; -use ratatui::widgets::Block; -use ratatui::widgets::StatefulWidgetRef; -use ratatui::widgets::WidgetRef; - -use super::chat_composer_history::ChatComposerHistory; -use super::command_popup::CommandItem; -use super::command_popup::CommandPopup; -use super::command_popup::CommandPopupFlags; -use super::file_search_popup::FileSearchPopup; -use super::footer::FooterMode; -use super::footer::FooterProps; -use super::footer::esc_hint_mode; -use super::footer::footer_height; -use super::footer::inset_footer_hint_area; -use super::footer::render_footer; -use super::footer::render_footer_hint_items; -use super::footer::reset_mode_after_activity; -use super::footer::toggle_shortcut_mode; -use super::paste_burst::CharDecision; -use super::paste_burst::PasteBurst; -use super::skill_popup::SkillPopup; -use crate::bottom_pane::paste_burst::FlushResult; -use crate::bottom_pane::prompt_args::expand_custom_prompt; -use crate::bottom_pane::prompt_args::expand_if_numeric_with_positional_args; -use crate::bottom_pane::prompt_args::parse_slash_name; -use crate::bottom_pane::prompt_args::prompt_argument_names; -use crate::bottom_pane::prompt_args::prompt_command_with_arg_placeholders; -use crate::bottom_pane::prompt_args::prompt_has_numeric_placeholders; -use crate::render::Insets; -use crate::render::RectExt; -use crate::render::renderable::Renderable; -use crate::slash_command::SlashCommand; -use crate::slash_command::built_in_slash_commands; -use crate::style::user_message_style; -use codex_common::fuzzy_match::fuzzy_match; -use codex_protocol::custom_prompts::CustomPrompt; -use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; -use codex_protocol::models::local_image_label_text; -use codex_protocol::user_input::ByteRange; -use codex_protocol::user_input::TextElement; - -use crate::app_event::AppEvent; -use crate::app_event_sender::AppEventSender; -use crate::bottom_pane::LocalImageAttachment; -use crate::bottom_pane::textarea::TextArea; -use crate::bottom_pane::textarea::TextAreaState; -use crate::clipboard_paste::normalize_pasted_path; -use crate::clipboard_paste::pasted_image_format; -use crate::history_cell; -use crate::ui_consts::LIVE_PREFIX_COLS; -use codex_core::skills::model::SkillMetadata; -use codex_file_search::FileMatch; -use std::cell::RefCell; -use std::collections::HashMap; -use std::collections::HashSet; -use std::collections::VecDeque; -use std::path::PathBuf; -use std::time::Duration; -use std::time::Instant; - -fn windows_degraded_sandbox_active() -> bool { - cfg!(target_os = "windows") - && codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED - && codex_core::get_platform_sandbox().is_some() - && !codex_core::is_windows_elevated_sandbox_enabled() -} - -/// If the pasted content exceeds this number of characters, replace it with a -/// placeholder in the UI. -const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000; - -/// Result returned when the user interacts with the text area. -#[derive(Debug, PartialEq)] -pub enum InputResult { - Submitted { - text: String, - text_elements: Vec, - }, - Queued { - text: String, - text_elements: Vec, - }, - Command(SlashCommand), - CommandWithArgs(SlashCommand, String), - None, -} - -#[derive(Clone, Debug, PartialEq)] -struct AttachedImage { - placeholder: String, - path: PathBuf, -} - -enum PromptSelectionMode { - Completion, - Submit, -} - -enum PromptSelectionAction { - Insert { - text: String, - cursor: Option, - }, - Submit { - text: String, - text_elements: Vec, - }, -} - -pub(crate) struct ChatComposer { - textarea: TextArea, - textarea_state: RefCell, - active_popup: ActivePopup, - app_event_tx: AppEventSender, - history: ChatComposerHistory, - quit_shortcut_expires_at: Option, - quit_shortcut_key: KeyBinding, - esc_backtrack_hint: bool, - use_shift_enter_hint: bool, - dismissed_file_popup_token: Option, - current_file_query: Option, - pending_pastes: Vec<(String, String)>, - large_paste_counters: HashMap, - has_focus: bool, - attached_images: Vec, - placeholder_text: String, - is_task_running: bool, - /// When false, the composer is temporarily read-only (e.g. during sandbox setup). - input_enabled: bool, - input_disabled_placeholder: Option, - /// Non-bracketed paste burst tracker (see `bottom_pane/paste_burst.rs`). - paste_burst: PasteBurst, - // When true, disables paste-burst logic and inserts characters immediately. - disable_paste_burst: bool, - custom_prompts: Vec, - footer_mode: FooterMode, - footer_hint_override: Option>, - footer_flash: Option, - context_window_percent: Option, - context_window_used_tokens: Option, - transcript_scrolled: bool, - transcript_selection_active: bool, - transcript_scroll_position: Option<(usize, usize)>, - transcript_copy_selection_key: KeyBinding, - transcript_copy_feedback: Option, - skills: Option>, - dismissed_skill_popup_token: Option, - /// When enabled, `Enter` submits immediately and `Tab` requests queuing behavior. - steer_enabled: bool, - collaboration_modes_enabled: bool, -} - -#[derive(Clone, Debug)] -struct FooterFlash { - line: Line<'static>, - expires_at: Instant, -} - -/// Popup state – at most one can be visible at any time. -enum ActivePopup { - None, - Command(CommandPopup), - File(FileSearchPopup), - Skill(SkillPopup), -} - -const FOOTER_SPACING_HEIGHT: u16 = 0; - -impl ChatComposer { - pub fn new( - has_input_focus: bool, - app_event_tx: AppEventSender, - enhanced_keys_supported: bool, - placeholder_text: String, - disable_paste_burst: bool, - ) -> Self { - let use_shift_enter_hint = enhanced_keys_supported; - - let mut this = Self { - textarea: TextArea::new(), - textarea_state: RefCell::new(TextAreaState::default()), - active_popup: ActivePopup::None, - app_event_tx, - history: ChatComposerHistory::new(), - quit_shortcut_expires_at: None, - quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - esc_backtrack_hint: false, - use_shift_enter_hint, - dismissed_file_popup_token: None, - current_file_query: None, - pending_pastes: Vec::new(), - large_paste_counters: HashMap::new(), - has_focus: has_input_focus, - attached_images: Vec::new(), - placeholder_text, - is_task_running: false, - input_enabled: true, - input_disabled_placeholder: None, - paste_burst: PasteBurst::default(), - disable_paste_burst: false, - custom_prompts: Vec::new(), - footer_mode: FooterMode::ShortcutSummary, - footer_hint_override: None, - footer_flash: None, - context_window_percent: None, - context_window_used_tokens: None, - transcript_scrolled: false, - transcript_selection_active: false, - transcript_scroll_position: None, - transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), - transcript_copy_feedback: None, - skills: None, - dismissed_skill_popup_token: None, - steer_enabled: false, - collaboration_modes_enabled: false, - }; - // Apply configuration via the setter to keep side-effects centralized. - this.set_disable_paste_burst(disable_paste_burst); - this - } - - pub fn set_skill_mentions(&mut self, skills: Option>) { - self.skills = skills; - } - - /// Enables or disables "Steer" behavior for submission keys. - /// - /// When steer is enabled, `Enter` produces [`InputResult::Submitted`] (send immediately) and - /// `Tab` produces [`InputResult::Queued`] (eligible to queue if a task is running). - /// When steer is disabled, `Enter` produces [`InputResult::Queued`], preserving the default - /// "queue while a task is running" behavior. - pub fn set_steer_enabled(&mut self, enabled: bool) { - self.steer_enabled = enabled; - } - - pub fn set_collaboration_modes_enabled(&mut self, enabled: bool) { - self.collaboration_modes_enabled = enabled; - } - - fn layout_areas(&self, area: Rect) -> [Rect; 3] { - let footer_props = self.footer_props(); - let footer_hint_height = self - .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 { - ActivePopup::Command(popup) => { - Constraint::Max(popup.calculate_required_height(area.width)) - } - ActivePopup::File(popup) => Constraint::Max(popup.calculate_required_height()), - ActivePopup::Skill(popup) => { - Constraint::Max(popup.calculate_required_height(area.width)) - } - ActivePopup::None => Constraint::Max(footer_total_height), - }; - let [composer_rect, popup_rect] = - Layout::vertical([Constraint::Min(3), popup_constraint]).areas(area); - let textarea_rect = composer_rect.inset(Insets::tlbr(1, LIVE_PREFIX_COLS, 1, 1)); - [composer_rect, textarea_rect, popup_rect] - } - - fn footer_spacing(footer_hint_height: u16) -> u16 { - if footer_hint_height == 0 { - 0 - } else { - FOOTER_SPACING_HEIGHT - } - } - - /// Returns true if the composer currently contains no user input. - pub(crate) fn is_empty(&self) -> bool { - self.textarea.is_empty() - } - - /// Record the history metadata advertised by `SessionConfiguredEvent` so - /// that the composer can navigate cross-session history. - pub(crate) fn set_history_metadata(&mut self, log_id: u64, entry_count: usize) { - self.history.set_metadata(log_id, entry_count); - } - - /// Integrate an asynchronous response to an on-demand history lookup. If - /// the entry is present and the offset matches the current cursor we - /// immediately populate the textarea. - pub(crate) fn on_history_entry_response( - &mut self, - log_id: u64, - offset: usize, - entry: Option, - ) -> bool { - let Some(text) = self.history.on_entry_response(log_id, offset, entry) else { - return false; - }; - // Composer history (↑/↓) stores plain text only; no UI element ranges/attachments to restore here. - self.set_text_content(text, Vec::new(), Vec::new()); - true - } - - /// Integrate pasted text into the composer. - /// - /// Acts as the only place where paste text is integrated, both for: - /// - /// - Real/explicit paste events surfaced by the terminal, and - /// - Non-bracketed "paste bursts" that [`PasteBurst`](super::paste_burst::PasteBurst) buffers - /// and later flushes here. - /// - /// Behavior: - /// - /// - If the paste is larger than `LARGE_PASTE_CHAR_THRESHOLD` chars, inserts a placeholder - /// element (expanded on submit) and stores the full text in `pending_pastes`. - /// - Otherwise, if the paste looks like an image path, attaches the image and inserts a - /// trailing space so the user can keep typing naturally. - /// - Otherwise, inserts the pasted text directly into the textarea. - /// - /// In all cases, clears any paste-burst Enter suppression state so a real paste cannot affect - /// the next user Enter key, then syncs popup state. - pub fn handle_paste(&mut self, pasted: String) -> bool { - let pasted = pasted.replace("\r\n", "\n").replace('\r', "\n"); - let char_count = pasted.chars().count(); - if char_count > LARGE_PASTE_CHAR_THRESHOLD { - let placeholder = self.next_large_paste_placeholder(char_count); - self.textarea.insert_element(&placeholder); - self.pending_pastes.push((placeholder, pasted)); - } else if char_count > 1 && self.handle_paste_image_path(pasted.clone()) { - self.textarea.insert_str(" "); - } else { - self.textarea.insert_str(&pasted); - } - // Explicit paste events should not trigger Enter suppression. - self.paste_burst.clear_after_explicit_paste(); - self.sync_popups(); - true - } - - pub fn handle_paste_image_path(&mut self, pasted: String) -> bool { - let Some(path_buf) = normalize_pasted_path(&pasted) else { - return false; - }; - - // normalize_pasted_path already handles Windows → WSL path conversion, - // so we can directly try to read the image dimensions. - match image::image_dimensions(&path_buf) { - Ok((width, height)) => { - tracing::info!("OK: {pasted}"); - tracing::debug!("image dimensions={}x{}", width, height); - let format = pasted_image_format(&path_buf); - tracing::debug!("attached image format={}", format.label()); - self.attach_image(path_buf); - true - } - Err(err) => { - tracing::trace!("ERR: {err}"); - false - } - } - } - - /// Enable or disable paste-burst handling. - /// - /// `disable_paste_burst` is an escape hatch for terminals/platforms where the burst heuristic - /// is unwanted or has already been handled elsewhere. - /// - /// When transitioning from enabled → disabled, we "defuse" any in-flight burst state so it - /// cannot affect subsequent normal typing: - /// - /// - First, flush any held/buffered text immediately via - /// [`PasteBurst::flush_before_modified_input`], and feed it through `handle_paste(String)`. - /// This preserves user input and routes it through the same integration path as explicit - /// pastes (large-paste placeholders, image-path detection, and popup sync). - /// - Then clear the burst timing and Enter-suppression window via - /// [`PasteBurst::clear_after_explicit_paste`]. - /// - /// We intentionally do not use `clear_window_after_non_char()` here: it clears timing state - /// without emitting any buffered text, which can leave a non-empty buffer unable to flush - /// later (because `flush_if_due()` relies on `last_plain_char_time` to time out). - pub(crate) fn set_disable_paste_burst(&mut self, disabled: bool) { - let was_disabled = self.disable_paste_burst; - self.disable_paste_burst = disabled; - if disabled && !was_disabled { - if let Some(pasted) = self.paste_burst.flush_before_modified_input() { - self.handle_paste(pasted); - } - self.paste_burst.clear_after_explicit_paste(); - } - } - - /// 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; - } - - pub(crate) fn show_footer_flash(&mut self, line: Line<'static>, duration: Duration) { - let expires_at = Instant::now() - .checked_add(duration) - .unwrap_or_else(Instant::now); - self.footer_flash = Some(FooterFlash { line, expires_at }); - } - - pub(crate) fn footer_flash_visible(&self) -> bool { - self.footer_flash - .as_ref() - .is_some_and(|flash| Instant::now() < flash.expires_at) - } - - /// Replace the entire composer content with `text` and reset cursor. - /// This clears any pending paste payloads. - pub(crate) fn set_text_content( - &mut self, - text: String, - text_elements: Vec, - local_image_paths: Vec, - ) { - // Clear any existing content, placeholders, and attachments first. - self.textarea.set_text_clearing_elements(""); - self.pending_pastes.clear(); - self.attached_images.clear(); - - self.textarea.set_text_with_elements(&text, &text_elements); - - let image_placeholders: HashSet = text_elements - .iter() - .filter_map(|elem| elem.placeholder(&text).map(str::to_string)) - .collect(); - for (idx, path) in local_image_paths.into_iter().enumerate() { - let placeholder = local_image_label_text(idx + 1); - if image_placeholders.contains(&placeholder) { - self.attached_images - .push(AttachedImage { placeholder, path }); - } - } - - self.textarea.set_cursor(0); - self.sync_popups(); - } - - pub(crate) fn clear_for_ctrl_c(&mut self) -> Option { - if self.is_empty() { - return None; - } - let previous = self.current_text(); - self.set_text_content(String::new(), Vec::new(), Vec::new()); - self.history.reset_navigation(); - self.history.record_local_submission(&previous); - Some(previous) - } - - /// Get the current composer text. - pub(crate) fn current_text(&self) -> String { - self.textarea.text().to_string() - } - - pub(crate) fn text_elements(&self) -> Vec { - self.textarea.text_elements() - } - - #[cfg(test)] - pub(crate) fn local_image_paths(&self) -> Vec { - self.attached_images - .iter() - .map(|img| img.path.clone()) - .collect() - } - - pub(crate) fn local_images(&self) -> Vec { - self.attached_images - .iter() - .map(|img| LocalImageAttachment { - placeholder: img.placeholder.clone(), - path: img.path.clone(), - }) - .collect() - } - - fn prune_attached_images_for_submission(&mut self, text: &str, text_elements: &[TextElement]) { - if self.attached_images.is_empty() { - return; - } - let image_placeholders: HashSet<&str> = text_elements - .iter() - .filter_map(|elem| elem.placeholder(text)) - .collect(); - self.attached_images - .retain(|img| image_placeholders.contains(img.placeholder.as_str())); - } - - /// Insert an attachment placeholder and track it for the next submission. - pub fn attach_image(&mut self, path: PathBuf) { - let image_number = self.attached_images.len() + 1; - let placeholder = local_image_label_text(image_number); - // Insert as an element to match large paste placeholder behavior: - // styled distinctly and treated atomically for cursor/mutations. - self.textarea.insert_element(&placeholder); - self.attached_images - .push(AttachedImage { placeholder, path }); - } - - #[cfg(test)] - pub fn take_recent_submission_images(&mut self) -> Vec { - let images = std::mem::take(&mut self.attached_images); - images.into_iter().map(|img| img.path).collect() - } - - pub fn take_recent_submission_images_with_placeholders(&mut self) -> Vec { - let images = std::mem::take(&mut self.attached_images); - images - .into_iter() - .map(|img| LocalImageAttachment { - placeholder: img.placeholder, - path: img.path, - }) - .collect() - } - - /// Flushes any due paste-burst state. - /// - /// Call this from a UI tick to turn paste-burst transient state into explicit textarea edits: - /// - /// - If a burst times out, flush it via `handle_paste(String)`. - /// - If only the first ASCII char was held (flicker suppression) and no burst followed, emit it - /// as normal typed input. - /// - /// This also allows a single "held" ASCII char to render even when it turns out not to be part - /// of a paste burst. - pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool { - self.handle_paste_burst_flush(Instant::now()) - } - - /// Returns whether the composer is currently in any paste-burst related transient state. - /// - /// This includes actively buffering, having a non-empty burst buffer, or holding the first - /// ASCII char for flicker suppression. - pub(crate) fn is_in_paste_burst(&self) -> bool { - self.paste_burst.is_active() - } - - /// Returns a delay that reliably exceeds the paste-burst timing threshold. - /// - /// Use this in tests to avoid boundary flakiness around the `PasteBurst` timeout. - pub(crate) fn recommended_paste_flush_delay() -> Duration { - PasteBurst::recommended_flush_delay() - } - - /// Integrate results from an asynchronous file search. - pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec) { - // Only apply if user is still editing a token starting with `query`. - let current_opt = Self::current_at_token(&self.textarea); - let Some(current_token) = current_opt else { - return; - }; - - if !current_token.starts_with(&query) { - return; - } - - if let ActivePopup::File(popup) = &mut self.active_popup { - popup.set_matches(&query, matches); - } - } - - /// Show the transient "press again to quit" hint for `key`. - /// - /// The owner (`BottomPane`/`ChatWidget`) is responsible for scheduling a - /// redraw after [`super::QUIT_SHORTCUT_TIMEOUT`] so the hint can disappear - /// even when the UI is otherwise idle. - pub fn show_quit_shortcut_hint(&mut self, key: KeyBinding, has_focus: bool) { - self.quit_shortcut_expires_at = Instant::now() - .checked_add(super::QUIT_SHORTCUT_TIMEOUT) - .or_else(|| Some(Instant::now())); - self.quit_shortcut_key = key; - self.footer_mode = FooterMode::QuitShortcutReminder; - self.set_has_focus(has_focus); - } - - /// Clear the "press again to quit" hint immediately. - pub fn clear_quit_shortcut_hint(&mut self, has_focus: bool) { - self.quit_shortcut_expires_at = None; - self.footer_mode = reset_mode_after_activity(self.footer_mode); - self.set_has_focus(has_focus); - } - - /// Whether the quit shortcut hint should currently be shown. - /// - /// This is time-based rather than event-based: it may become false without - /// any additional user input, so the UI schedules a redraw when the hint - /// expires. - pub(crate) fn quit_shortcut_hint_visible(&self) -> bool { - self.quit_shortcut_expires_at - .is_some_and(|expires_at| Instant::now() < expires_at) - } - - fn next_large_paste_placeholder(&mut self, char_count: usize) -> String { - let base = format!("[Pasted Content {char_count} chars]"); - let next_suffix = self.large_paste_counters.entry(char_count).or_insert(0); - *next_suffix += 1; - if *next_suffix == 1 { - base - } else { - format!("{base} #{next_suffix}") - } - } - - pub(crate) fn insert_str(&mut self, text: &str) { - self.textarea.insert_str(text); - self.sync_popups(); - } - - /// Handle a key event coming from the main UI. - pub fn handle_key_event(&mut self, key_event: KeyEvent) -> (InputResult, bool) { - if !self.input_enabled { - return (InputResult::None, false); - } - - let result = match &mut self.active_popup { - ActivePopup::Command(_) => self.handle_key_event_with_slash_popup(key_event), - ActivePopup::File(_) => self.handle_key_event_with_file_popup(key_event), - ActivePopup::Skill(_) => self.handle_key_event_with_skill_popup(key_event), - ActivePopup::None => self.handle_key_event_without_popup(key_event), - }; - - // Update (or hide/show) popup after processing the key. - self.sync_popups(); - - result - } - - /// Return true if either the slash-command popup or the file-search popup is active. - pub(crate) fn popup_active(&self) -> bool { - !matches!(self.active_popup, ActivePopup::None) - } - - /// Handle key event when the slash-command popup is visible. - fn handle_key_event_with_slash_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { - if self.handle_shortcut_overlay_key(&key_event) { - return (InputResult::None, true); - } - if key_event.code == KeyCode::Esc { - let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running); - if next_mode != self.footer_mode { - self.footer_mode = next_mode; - return (InputResult::None, true); - } - } else { - self.footer_mode = reset_mode_after_activity(self.footer_mode); - } - let ActivePopup::Command(popup) = &mut self.active_popup else { - unreachable!(); - }; - - match key_event { - KeyEvent { - code: KeyCode::Up, .. - } - | KeyEvent { - code: KeyCode::Char('p'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - popup.move_up(); - (InputResult::None, true) - } - KeyEvent { - code: KeyCode::Down, - .. - } - | KeyEvent { - code: KeyCode::Char('n'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - popup.move_down(); - (InputResult::None, true) - } - KeyEvent { - code: KeyCode::Esc, .. - } => { - // Dismiss the slash popup; keep the current input untouched. - self.active_popup = ActivePopup::None; - (InputResult::None, true) - } - KeyEvent { - code: KeyCode::Tab, .. - } => { - // Ensure popup filtering/selection reflects the latest composer text - // before applying completion. - let first_line = self.textarea.text().lines().next().unwrap_or(""); - popup.on_composer_text_change(first_line.to_string()); - if let Some(sel) = popup.selected_item() { - let mut cursor_target: Option = None; - match sel { - CommandItem::Builtin(cmd) => { - if cmd == SlashCommand::Skills { - self.textarea.set_text_clearing_elements(""); - return (InputResult::Command(cmd), true); - } - - let starts_with_cmd = first_line - .trim_start() - .starts_with(&format!("/{}", cmd.command())); - if !starts_with_cmd { - // Slash completion replaces the buffer with plain text; drop elements. - self.textarea - .set_text_clearing_elements(&format!("/{} ", cmd.command())); - } - if !self.textarea.text().is_empty() { - cursor_target = Some(self.textarea.text().len()); - } - } - CommandItem::UserPrompt(idx) => { - if let Some(prompt) = popup.prompt(idx) { - match prompt_selection_action( - prompt, - first_line, - PromptSelectionMode::Completion, - &self.textarea.text_elements(), - ) { - PromptSelectionAction::Insert { text, cursor } => { - let target = cursor.unwrap_or(text.len()); - // Inserted prompt text is plain input; discard any elements. - self.textarea.set_text_clearing_elements(&text); - cursor_target = Some(target); - } - PromptSelectionAction::Submit { .. } => {} - } - } - } - } - if let Some(pos) = cursor_target { - self.textarea.set_cursor(pos); - } - } - (InputResult::None, true) - } - KeyEvent { - code: KeyCode::Enter, - modifiers: KeyModifiers::NONE, - .. - } => { - // If the current line starts with a custom prompt name and includes - // positional args for a numeric-style template, expand and submit - // immediately regardless of the popup selection. - let mut text = self.textarea.text().to_string(); - let mut text_elements = self.textarea.text_elements(); - if !self.pending_pastes.is_empty() { - let (expanded, expanded_elements) = - Self::expand_pending_pastes(&text, text_elements, &self.pending_pastes); - text = expanded; - text_elements = expanded_elements; - } - let first_line = text.lines().next().unwrap_or(""); - if let Some((name, _rest, _rest_offset)) = parse_slash_name(first_line) - && let Some(prompt_name) = name.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) - && let Some(prompt) = self.custom_prompts.iter().find(|p| p.name == prompt_name) - && let Some(expanded) = - expand_if_numeric_with_positional_args(prompt, first_line, &text_elements) - { - self.prune_attached_images_for_submission( - &expanded.text, - &expanded.text_elements, - ); - self.pending_pastes.clear(); - self.textarea.set_text_clearing_elements(""); - return ( - InputResult::Submitted { - text: expanded.text, - text_elements: expanded.text_elements, - }, - true, - ); - } - - if let Some(sel) = popup.selected_item() { - match sel { - CommandItem::Builtin(cmd) => { - self.textarea.set_text_clearing_elements(""); - return (InputResult::Command(cmd), true); - } - CommandItem::UserPrompt(idx) => { - if let Some(prompt) = popup.prompt(idx) { - match prompt_selection_action( - prompt, - first_line, - PromptSelectionMode::Submit, - &self.textarea.text_elements(), - ) { - PromptSelectionAction::Submit { - text, - text_elements, - } => { - self.prune_attached_images_for_submission( - &text, - &text_elements, - ); - self.textarea.set_text_clearing_elements(""); - return ( - InputResult::Submitted { - text, - text_elements, - }, - true, - ); - } - PromptSelectionAction::Insert { text, cursor } => { - let target = cursor.unwrap_or(text.len()); - // Inserted prompt text is plain input; discard any elements. - self.textarea.set_text_clearing_elements(&text); - self.textarea.set_cursor(target); - return (InputResult::None, true); - } - } - } - return (InputResult::None, true); - } - } - } - // Fallback to default newline handling if no command selected. - self.handle_key_event_without_popup(key_event) - } - input => self.handle_input_basic(input), - } - } - - #[inline] - /// Clamp a cursor index to a UTF-8 char boundary. - fn clamp_to_char_boundary(text: &str, pos: usize) -> usize { - let mut p = pos.min(text.len()); - if p < text.len() && !text.is_char_boundary(p) { - p = text - .char_indices() - .map(|(i, _)| i) - .take_while(|&i| i <= p) - .last() - .unwrap_or(0); - } - p - } - - /// Handle non-ASCII character input (often IME) while still supporting paste-burst detection. - /// - /// This handler exists because non-ASCII input often comes from IMEs, where characters can - /// legitimately arrive in short bursts that should **not** be treated as paste. - /// - /// The key differences from the ASCII path: - /// - /// - We never hold the first character (`PasteBurst::on_plain_char_no_hold`), because holding a - /// non-ASCII char can feel like dropped input. - /// - If a burst is detected, we may need to retroactively remove already-inserted text before - /// the cursor and move it into the paste buffer (see `PasteBurst::decide_begin_buffer`). - /// - /// Because this path mixes "insert immediately" with "maybe retro-grab later", it must clamp - /// the cursor to a UTF-8 char boundary before slicing `textarea.text()`. - #[inline] - fn handle_non_ascii_char(&mut self, input: KeyEvent, now: Instant) -> (InputResult, bool) { - if self.disable_paste_burst { - // When burst detection is disabled, treat IME/non-ASCII input as normal typing. - // In particular, do not retro-capture or buffer already-inserted prefix text. - self.textarea.input(input); - let text_after = self.textarea.text(); - self.pending_pastes - .retain(|(placeholder, _)| text_after.contains(placeholder)); - return (InputResult::None, true); - } - if let KeyEvent { - code: KeyCode::Char(ch), - .. - } = input - { - if self.paste_burst.try_append_char_if_active(ch, now) { - return (InputResult::None, true); - } - // Non-ASCII input often comes from IMEs and can arrive in quick bursts. - // We do not want to hold the first char (flicker suppression) on this path, but we - // still want to detect paste-like bursts. Before applying any non-ASCII input, flush - // any existing burst buffer (including a pending first char from the ASCII path) so - // we don't carry that transient state forward. - if let Some(pasted) = self.paste_burst.flush_before_modified_input() { - self.handle_paste(pasted); - } - if let Some(decision) = self.paste_burst.on_plain_char_no_hold(now) { - match decision { - CharDecision::BufferAppend => { - self.paste_burst.append_char_to_buffer(ch, now); - return (InputResult::None, true); - } - CharDecision::BeginBuffer { retro_chars } => { - // For non-ASCII we inserted prior chars immediately, so if this turns out - // to be paste-like we need to retroactively grab & remove the already- - // inserted prefix from the textarea before buffering the burst. - let cur = self.textarea.cursor(); - let txt = self.textarea.text(); - let safe_cur = Self::clamp_to_char_boundary(txt, cur); - let before = &txt[..safe_cur]; - if let Some(grab) = - self.paste_burst - .decide_begin_buffer(now, before, retro_chars as usize) - { - if !grab.grabbed.is_empty() { - self.textarea.replace_range(grab.start_byte..safe_cur, ""); - } - // seed the paste burst buffer with everything (grabbed + new) - self.paste_burst.append_char_to_buffer(ch, now); - return (InputResult::None, true); - } - // If decide_begin_buffer opted not to start buffering, - // fall through to normal insertion below. - } - _ => unreachable!("on_plain_char_no_hold returned unexpected variant"), - } - } - } - if let Some(pasted) = self.paste_burst.flush_before_modified_input() { - self.handle_paste(pasted); - } - self.textarea.input(input); - let text_after = self.textarea.text(); - self.pending_pastes - .retain(|(placeholder, _)| text_after.contains(placeholder)); - (InputResult::None, true) - } - - /// Handle key events when file search popup is visible. - fn handle_key_event_with_file_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { - if self.handle_shortcut_overlay_key(&key_event) { - return (InputResult::None, true); - } - if key_event.code == KeyCode::Esc { - let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running); - if next_mode != self.footer_mode { - self.footer_mode = next_mode; - return (InputResult::None, true); - } - } else { - self.footer_mode = reset_mode_after_activity(self.footer_mode); - } - let ActivePopup::File(popup) = &mut self.active_popup else { - unreachable!(); - }; - - match key_event { - KeyEvent { - code: KeyCode::Up, .. - } - | KeyEvent { - code: KeyCode::Char('p'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - popup.move_up(); - (InputResult::None, true) - } - KeyEvent { - code: KeyCode::Down, - .. - } - | KeyEvent { - code: KeyCode::Char('n'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - popup.move_down(); - (InputResult::None, true) - } - KeyEvent { - code: KeyCode::Esc, .. - } => { - // Hide popup without modifying text, remember token to avoid immediate reopen. - if let Some(tok) = Self::current_at_token(&self.textarea) { - self.dismissed_file_popup_token = Some(tok); - } - self.active_popup = ActivePopup::None; - (InputResult::None, true) - } - KeyEvent { - code: KeyCode::Tab, .. - } - | KeyEvent { - code: KeyCode::Enter, - modifiers: KeyModifiers::NONE, - .. - } => { - let Some(sel) = popup.selected_match() else { - self.active_popup = ActivePopup::None; - return (InputResult::None, true); - }; - - let sel_path = sel.to_string(); - // If selected path looks like an image (png/jpeg), attach as image instead of inserting text. - let is_image = Self::is_image_path(&sel_path); - if is_image { - // Determine dimensions; if that fails fall back to normal path insertion. - let path_buf = PathBuf::from(&sel_path); - match image::image_dimensions(&path_buf) { - Ok((width, height)) => { - tracing::debug!("selected image dimensions={}x{}", width, height); - // Remove the current @token (mirror logic from insert_selected_path without inserting text) - // using the flat text and byte-offset cursor API. - let cursor_offset = self.textarea.cursor(); - let text = self.textarea.text(); - // Clamp to a valid char boundary to avoid panics when slicing. - let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset); - let before_cursor = &text[..safe_cursor]; - let after_cursor = &text[safe_cursor..]; - - // Determine token boundaries in the full text. - let start_idx = before_cursor - .char_indices() - .rfind(|(_, c)| c.is_whitespace()) - .map(|(idx, c)| idx + c.len_utf8()) - .unwrap_or(0); - let end_rel_idx = after_cursor - .char_indices() - .find(|(_, c)| c.is_whitespace()) - .map(|(idx, _)| idx) - .unwrap_or(after_cursor.len()); - let end_idx = safe_cursor + end_rel_idx; - - self.textarea.replace_range(start_idx..end_idx, ""); - self.textarea.set_cursor(start_idx); - - self.attach_image(path_buf); - // Add a trailing space to keep typing fluid. - self.textarea.insert_str(" "); - } - Err(err) => { - tracing::trace!("image dimensions lookup failed: {err}"); - // Fallback to plain path insertion if metadata read fails. - self.insert_selected_path(&sel_path); - } - } - } else { - // Non-image: inserting file path. - self.insert_selected_path(&sel_path); - } - // No selection: treat Enter as closing the popup/session. - self.active_popup = ActivePopup::None; - (InputResult::None, true) - } - input => self.handle_input_basic(input), - } - } - - fn handle_key_event_with_skill_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { - if self.handle_shortcut_overlay_key(&key_event) { - return (InputResult::None, true); - } - self.footer_mode = reset_mode_after_activity(self.footer_mode); - - let ActivePopup::Skill(popup) = &mut self.active_popup else { - unreachable!(); - }; - - match key_event { - KeyEvent { - code: KeyCode::Up, .. - } - | KeyEvent { - code: KeyCode::Char('p'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - popup.move_up(); - (InputResult::None, true) - } - KeyEvent { - code: KeyCode::Down, - .. - } - | KeyEvent { - code: KeyCode::Char('n'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - popup.move_down(); - (InputResult::None, true) - } - KeyEvent { - code: KeyCode::Esc, .. - } => { - if let Some(tok) = self.current_skill_token() { - self.dismissed_skill_popup_token = Some(tok); - } - self.active_popup = ActivePopup::None; - (InputResult::None, true) - } - KeyEvent { - code: KeyCode::Tab, .. - } - | KeyEvent { - code: KeyCode::Enter, - modifiers: KeyModifiers::NONE, - .. - } => { - let selected = popup.selected_skill().map(|skill| skill.name.clone()); - if let Some(name) = selected { - self.insert_selected_skill(&name); - } - self.active_popup = ActivePopup::None; - (InputResult::None, true) - } - input => self.handle_input_basic(input), - } - } - - fn is_image_path(path: &str) -> bool { - let lower = path.to_ascii_lowercase(); - lower.ends_with(".png") || lower.ends_with(".jpg") || lower.ends_with(".jpeg") - } - - fn trim_text_elements( - original: &str, - trimmed: &str, - elements: Vec, - ) -> Vec { - if trimmed.is_empty() || elements.is_empty() { - return Vec::new(); - } - let trimmed_start = original.len().saturating_sub(original.trim_start().len()); - let trimmed_end = trimmed_start.saturating_add(trimmed.len()); - - elements - .into_iter() - .filter_map(|elem| { - let start = elem.byte_range.start; - let end = elem.byte_range.end; - if end <= trimmed_start || start >= trimmed_end { - return None; - } - let new_start = start.saturating_sub(trimmed_start); - let new_end = end.saturating_sub(trimmed_start).min(trimmed.len()); - if new_start >= new_end { - return None; - } - let placeholder = trimmed.get(new_start..new_end).map(str::to_string); - Some(TextElement::new( - ByteRange { - start: new_start, - end: new_end, - }, - placeholder, - )) - }) - .collect() - } - - /// Expand large-paste placeholders using element ranges and rebuild other element spans. - fn expand_pending_pastes( - text: &str, - mut elements: Vec, - pending_pastes: &[(String, String)], - ) -> (String, Vec) { - if pending_pastes.is_empty() || elements.is_empty() { - return (text.to_string(), elements); - } - - let mut pending_by_placeholder: HashMap<&str, VecDeque<&str>> = HashMap::new(); - for (placeholder, actual) in pending_pastes { - pending_by_placeholder - .entry(placeholder.as_str()) - .or_default() - .push_back(actual.as_str()); - } - - elements.sort_by_key(|elem| elem.byte_range.start); - - let mut rebuilt = String::with_capacity(text.len()); - let mut rebuilt_elements = Vec::with_capacity(elements.len()); - let mut cursor = 0usize; - - for elem in elements { - let start = elem.byte_range.start.min(text.len()); - let end = elem.byte_range.end.min(text.len()); - if start > end { - continue; - } - if start > cursor { - rebuilt.push_str(&text[cursor..start]); - } - let elem_text = &text[start..end]; - let placeholder = elem.placeholder(text).map(str::to_string); - let replacement = placeholder - .as_deref() - .and_then(|ph| pending_by_placeholder.get_mut(ph)) - .and_then(VecDeque::pop_front); - if let Some(actual) = replacement { - rebuilt.push_str(actual); - } else { - let new_start = rebuilt.len(); - rebuilt.push_str(elem_text); - let new_end = rebuilt.len(); - let placeholder = placeholder.or_else(|| Some(elem_text.to_string())); - rebuilt_elements.push(TextElement::new( - ByteRange { - start: new_start, - end: new_end, - }, - placeholder, - )); - } - cursor = end; - } - - if cursor < text.len() { - rebuilt.push_str(&text[cursor..]); - } - - (rebuilt, rebuilt_elements) - } - - fn skills_enabled(&self) -> bool { - self.skills.as_ref().is_some_and(|s| !s.is_empty()) - } - - pub fn skills(&self) -> Option<&Vec> { - self.skills.as_ref() - } - - /// Extract a token prefixed with `prefix` under the cursor, if any. - /// - /// The returned string **does not** include the prefix. - /// - /// Behavior: - /// - The cursor may be anywhere *inside* the token (including on the - /// leading prefix). It does **not** need to be at the end of the line. - /// - A token is delimited by ASCII whitespace (space, tab, newline). - /// - If the token under the cursor starts with `prefix`, that token is - /// returned without the leading prefix. When `allow_empty` is true, a - /// lone prefix character yields `Some(String::new())` to surface hints. - fn current_prefixed_token( - textarea: &TextArea, - prefix: char, - allow_empty: bool, - ) -> Option { - let cursor_offset = textarea.cursor(); - let text = textarea.text(); - - // Adjust the provided byte offset to the nearest valid char boundary at or before it. - let mut safe_cursor = cursor_offset.min(text.len()); - // If we're not on a char boundary, move back to the start of the current char. - if safe_cursor < text.len() && !text.is_char_boundary(safe_cursor) { - // Find the last valid boundary <= cursor_offset. - safe_cursor = text - .char_indices() - .map(|(i, _)| i) - .take_while(|&i| i <= cursor_offset) - .last() - .unwrap_or(0); - } - - // Split the line around the (now safe) cursor position. - let before_cursor = &text[..safe_cursor]; - let after_cursor = &text[safe_cursor..]; - - // Detect whether we're on whitespace at the cursor boundary. - let at_whitespace = if safe_cursor < text.len() { - text[safe_cursor..] - .chars() - .next() - .map(char::is_whitespace) - .unwrap_or(false) - } else { - false - }; - - // Left candidate: token containing the cursor position. - let start_left = before_cursor - .char_indices() - .rfind(|(_, c)| c.is_whitespace()) - .map(|(idx, c)| idx + c.len_utf8()) - .unwrap_or(0); - let end_left_rel = after_cursor - .char_indices() - .find(|(_, c)| c.is_whitespace()) - .map(|(idx, _)| idx) - .unwrap_or(after_cursor.len()); - let end_left = safe_cursor + end_left_rel; - let token_left = if start_left < end_left { - Some(&text[start_left..end_left]) - } else { - None - }; - - // Right candidate: token immediately after any whitespace from the cursor. - let ws_len_right: usize = after_cursor - .chars() - .take_while(|c| c.is_whitespace()) - .map(char::len_utf8) - .sum(); - let start_right = safe_cursor + ws_len_right; - let end_right_rel = text[start_right..] - .char_indices() - .find(|(_, c)| c.is_whitespace()) - .map(|(idx, _)| idx) - .unwrap_or(text.len() - start_right); - let end_right = start_right + end_right_rel; - let token_right = if start_right < end_right { - Some(&text[start_right..end_right]) - } else { - None - }; - - let prefix_str = prefix.to_string(); - let left_match = token_left.filter(|t| t.starts_with(prefix)); - let right_match = token_right.filter(|t| t.starts_with(prefix)); - - let left_prefixed = left_match.map(|t| t[prefix.len_utf8()..].to_string()); - let right_prefixed = right_match.map(|t| t[prefix.len_utf8()..].to_string()); - - if at_whitespace { - if right_prefixed.is_some() { - return right_prefixed; - } - if token_left.is_some_and(|t| t == prefix_str) { - return allow_empty.then(String::new); - } - return left_prefixed; - } - if after_cursor.starts_with(prefix) { - return right_prefixed.or(left_prefixed); - } - left_prefixed.or(right_prefixed) - } - - /// Extract the `@token` that the cursor is currently positioned on, if any. - /// - /// The returned string **does not** include the leading `@`. - fn current_at_token(textarea: &TextArea) -> Option { - Self::current_prefixed_token(textarea, '@', false) - } - - fn current_skill_token(&self) -> Option { - if !self.skills_enabled() { - return None; - } - Self::current_prefixed_token(&self.textarea, '$', true) - } - - /// Replace the active `@token` (the one under the cursor) with `path`. - /// - /// The algorithm mirrors `current_at_token` so replacement works no matter - /// where the cursor is within the token and regardless of how many - /// `@tokens` exist in the line. - fn insert_selected_path(&mut self, path: &str) { - let cursor_offset = self.textarea.cursor(); - let text = self.textarea.text(); - // Clamp to a valid char boundary to avoid panics when slicing. - let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset); - - let before_cursor = &text[..safe_cursor]; - let after_cursor = &text[safe_cursor..]; - - // Determine token boundaries. - let start_idx = before_cursor - .char_indices() - .rfind(|(_, c)| c.is_whitespace()) - .map(|(idx, c)| idx + c.len_utf8()) - .unwrap_or(0); - - let end_rel_idx = after_cursor - .char_indices() - .find(|(_, c)| c.is_whitespace()) - .map(|(idx, _)| idx) - .unwrap_or(after_cursor.len()); - let end_idx = safe_cursor + end_rel_idx; - - // If the path contains whitespace, wrap it in double quotes so the - // local prompt arg parser treats it as a single argument. Avoid adding - // quotes when the path already contains one to keep behavior simple. - let needs_quotes = path.chars().any(char::is_whitespace); - let inserted = if needs_quotes && !path.contains('"') { - format!("\"{path}\"") - } else { - path.to_string() - }; - - // Replace the slice `[start_idx, end_idx)` with the chosen path and a trailing space. - let mut new_text = - String::with_capacity(text.len() - (end_idx - start_idx) + inserted.len() + 1); - new_text.push_str(&text[..start_idx]); - new_text.push_str(&inserted); - new_text.push(' '); - new_text.push_str(&text[end_idx..]); - - // Path replacement is plain text; rebuild without carrying elements. - self.textarea.set_text_clearing_elements(&new_text); - let new_cursor = start_idx.saturating_add(inserted.len()).saturating_add(1); - self.textarea.set_cursor(new_cursor); - } - - fn insert_selected_skill(&mut self, skill_name: &str) { - let cursor_offset = self.textarea.cursor(); - let text = self.textarea.text(); - let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset); - - let before_cursor = &text[..safe_cursor]; - let after_cursor = &text[safe_cursor..]; - - let start_idx = before_cursor - .char_indices() - .rfind(|(_, c)| c.is_whitespace()) - .map(|(idx, c)| idx + c.len_utf8()) - .unwrap_or(0); - - let end_rel_idx = after_cursor - .char_indices() - .find(|(_, c)| c.is_whitespace()) - .map(|(idx, _)| idx) - .unwrap_or(after_cursor.len()); - let end_idx = safe_cursor + end_rel_idx; - - let inserted = format!("${skill_name}"); - - let mut new_text = - String::with_capacity(text.len() - (end_idx - start_idx) + inserted.len() + 1); - new_text.push_str(&text[..start_idx]); - new_text.push_str(&inserted); - new_text.push(' '); - new_text.push_str(&text[end_idx..]); - - // Skill insertion rebuilds plain text, so drop existing elements. - self.textarea.set_text_clearing_elements(&new_text); - let new_cursor = start_idx.saturating_add(inserted.len()).saturating_add(1); - self.textarea.set_cursor(new_cursor); - } - - /// Prepare text for submission/queuing. Returns None if submission should be suppressed. - /// On success, clears pending paste payloads because placeholders have been expanded. - fn prepare_submission_text(&mut self) -> Option<(String, Vec)> { - let mut text = self.textarea.text().to_string(); - let original_input = text.clone(); - let original_text_elements = self.textarea.text_elements(); - let original_local_image_paths = self - .attached_images - .iter() - .map(|img| img.path.clone()) - .collect::>(); - let original_pending_pastes = self.pending_pastes.clone(); - let mut text_elements = original_text_elements.clone(); - let input_starts_with_space = original_input.starts_with(' '); - self.textarea.set_text_clearing_elements(""); - - if !self.pending_pastes.is_empty() { - // Expand placeholders so element byte ranges stay aligned. - let (expanded, expanded_elements) = - Self::expand_pending_pastes(&text, text_elements, &self.pending_pastes); - text = expanded; - text_elements = expanded_elements; - } - - let expanded_input = text.clone(); - - // If there is neither text nor attachments, suppress submission entirely. - text = text.trim().to_string(); - text_elements = Self::trim_text_elements(&expanded_input, &text, text_elements); - - if let Some((name, _rest, _rest_offset)) = parse_slash_name(&text) { - let treat_as_plain_text = input_starts_with_space || name.contains('/'); - if !treat_as_plain_text { - let is_builtin = - Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled) - .any(|(command_name, _)| command_name == name); - let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:"); - let is_known_prompt = name - .strip_prefix(&prompt_prefix) - .map(|prompt_name| { - self.custom_prompts - .iter() - .any(|prompt| prompt.name == prompt_name) - }) - .unwrap_or(false); - if !is_builtin && !is_known_prompt { - let message = format!( - r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."# - ); - self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::new_info_event(message, None), - ))); - self.set_text_content( - original_input.clone(), - original_text_elements, - original_local_image_paths, - ); - self.pending_pastes.clone_from(&original_pending_pastes); - self.textarea.set_cursor(original_input.len()); - return None; - } - } - } - - let expanded_prompt = - match expand_custom_prompt(&text, &text_elements, &self.custom_prompts) { - Ok(expanded) => expanded, - Err(err) => { - self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::new_error_event(err.user_message()), - ))); - self.set_text_content( - original_input.clone(), - original_text_elements, - original_local_image_paths, - ); - self.pending_pastes.clone_from(&original_pending_pastes); - self.textarea.set_cursor(original_input.len()); - return None; - } - }; - if let Some(expanded) = expanded_prompt { - text = expanded.text; - text_elements = expanded.text_elements; - } - // Custom prompt expansion can remove or rewrite image placeholders, so prune any - // attachments that no longer have a corresponding placeholder in the expanded text. - self.prune_attached_images_for_submission(&text, &text_elements); - if text.is_empty() && self.attached_images.is_empty() { - return None; - } - if !text.is_empty() { - self.history.record_local_submission(&text); - } - // Placeholder elements have been expanded into real text, so payloads can be dropped. - self.pending_pastes.clear(); - Some((text, text_elements)) - } - - /// Common logic for handling message submission/queuing. - /// Returns the appropriate InputResult based on `should_queue`. - fn handle_submission(&mut self, should_queue: bool) -> (InputResult, bool) { - self.handle_submission_with_time(should_queue, Instant::now()) - } - - fn handle_submission_with_time( - &mut self, - should_queue: bool, - now: Instant, - ) -> (InputResult, bool) { - // If the first line is a bare built-in slash command (no args), - // dispatch it even when the slash popup isn't visible. This preserves - // the workflow: type a prefix ("/di"), press Tab to complete to - // "/diff ", then press Enter/Ctrl+Shift+Q to run it. Tab moves the cursor beyond - // the '/name' token and our caret-based heuristic hides the popup, - // but Enter/Ctrl+Shift+Q should still dispatch the command rather than submit - // literal text. - if let Some(result) = self.try_dispatch_bare_slash_command() { - return (result, true); - } - - // If we're in a paste-like burst capture, treat Enter/Ctrl+Shift+Q as part of the burst - // and accumulate it rather than submitting or inserting immediately. - // Do not treat as paste inside a slash-command context. - let in_slash_context = matches!(self.active_popup, ActivePopup::Command(_)) - || self - .textarea - .text() - .lines() - .next() - .unwrap_or("") - .starts_with('/'); - if !self.disable_paste_burst - && self.paste_burst.is_active() - && !in_slash_context - && self.paste_burst.append_newline_if_active(now) - { - return (InputResult::None, true); - } - - // During a paste-like burst, treat Enter/Ctrl+Shift+Q as a newline instead of submit. - if !in_slash_context - && !self.disable_paste_burst - && self - .paste_burst - .newline_should_insert_instead_of_submit(now) - { - self.textarea.insert_str("\n"); - self.paste_burst.extend_window(now); - return (InputResult::None, true); - } - - let original_input = self.textarea.text().to_string(); - let original_text_elements = self.textarea.text_elements(); - let original_local_image_paths = self - .attached_images - .iter() - .map(|img| img.path.clone()) - .collect::>(); - let original_pending_pastes = self.pending_pastes.clone(); - if let Some(result) = self.try_dispatch_slash_command_with_args() { - return (result, true); - } - - if let Some((text, text_elements)) = self.prepare_submission_text() { - if should_queue { - ( - InputResult::Queued { - text, - text_elements, - }, - true, - ) - } else { - // Do not clear attached_images here; ChatWidget drains them via take_recent_submission_images(). - ( - InputResult::Submitted { - text, - text_elements, - }, - true, - ) - } - } else { - // Restore text if submission was suppressed. - self.set_text_content( - original_input, - original_text_elements, - original_local_image_paths, - ); - self.pending_pastes = original_pending_pastes; - (InputResult::None, true) - } - } - - /// Check if the first line is a bare slash command (no args) and dispatch it. - /// Returns Some(InputResult) if a command was dispatched, None otherwise. - fn try_dispatch_bare_slash_command(&mut self) -> Option { - let first_line = self.textarea.text().lines().next().unwrap_or(""); - if let Some((name, rest, _rest_offset)) = parse_slash_name(first_line) - && rest.is_empty() - && let Some((_n, cmd)) = - Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled) - .find(|(n, _)| *n == name) - { - self.textarea.set_text_clearing_elements(""); - Some(InputResult::Command(cmd)) - } else { - None - } - } - - /// Check if the input is a slash command with args (e.g., /review args) and dispatch it. - /// Returns Some(InputResult) if a command was dispatched, None otherwise. - fn try_dispatch_slash_command_with_args(&mut self) -> Option { - let original_input = self.textarea.text().to_string(); - let input_starts_with_space = original_input.starts_with(' '); - - if !input_starts_with_space { - let text = self.textarea.text().to_string(); - if let Some((name, rest, _rest_offset)) = parse_slash_name(&text) - && !rest.is_empty() - && !name.contains('/') - && let Some((_n, cmd)) = - Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled) - .find(|(command_name, _)| *command_name == name) - && cmd == SlashCommand::Review - { - self.textarea.set_text_clearing_elements(""); - return Some(InputResult::CommandWithArgs(cmd, rest.to_string())); - } - } - None - } - - /// Handle key event when no popup is visible. - fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { - if self.handle_shortcut_overlay_key(&key_event) { - return (InputResult::None, true); - } - if key_event.code == KeyCode::Esc { - if self.is_empty() { - let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running); - if next_mode != self.footer_mode { - self.footer_mode = next_mode; - return (InputResult::None, true); - } - } - } else { - self.footer_mode = reset_mode_after_activity(self.footer_mode); - } - match key_event { - KeyEvent { - code: KeyCode::Char('d'), - modifiers: crossterm::event::KeyModifiers::CONTROL, - kind: KeyEventKind::Press, - .. - } if self.is_empty() => (InputResult::None, false), - // ------------------------------------------------------------- - // History navigation (Up / Down) – only when the composer is not - // empty or when the cursor is at the correct position, to avoid - // interfering with normal cursor movement. - // ------------------------------------------------------------- - KeyEvent { - code: KeyCode::Up | KeyCode::Down, - .. - } - | KeyEvent { - code: KeyCode::Char('p') | KeyCode::Char('n'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - if self - .history - .should_handle_navigation(self.textarea.text(), self.textarea.cursor()) - { - let replace_text = match key_event.code { - KeyCode::Up => self.history.navigate_up(&self.app_event_tx), - KeyCode::Down => self.history.navigate_down(&self.app_event_tx), - KeyCode::Char('p') => self.history.navigate_up(&self.app_event_tx), - KeyCode::Char('n') => self.history.navigate_down(&self.app_event_tx), - _ => unreachable!(), - }; - if let Some(text) = replace_text { - self.set_text_content(text, Vec::new(), Vec::new()); - return (InputResult::None, true); - } - } - self.handle_input_basic(key_event) - } - KeyEvent { - code: KeyCode::Tab, - modifiers: KeyModifiers::NONE, - kind: KeyEventKind::Press, - .. - } if self.is_task_running => self.handle_submission(true), - KeyEvent { - code: KeyCode::Enter, - modifiers: KeyModifiers::NONE, - .. - } => { - let should_queue = !self.steer_enabled; - self.handle_submission(should_queue) - } - input => self.handle_input_basic(input), - } - } - - /// Applies any due `PasteBurst` flush at time `now`. - /// - /// Converts [`PasteBurst::flush_if_due`] results into concrete textarea mutations. - /// - /// Callers: - /// - /// - UI ticks via [`ChatComposer::flush_paste_burst_if_due`], so held first-chars can render. - /// - Input handling via [`ChatComposer::handle_input_basic`], so a due burst does not lag. - fn handle_paste_burst_flush(&mut self, now: Instant) -> bool { - match self.paste_burst.flush_if_due(now) { - FlushResult::Paste(pasted) => { - self.handle_paste(pasted); - true - } - FlushResult::Typed(ch) => { - // Mirror insert_str() behavior so popups stay in sync when a - // pending fast char flushes as normal typed input. - self.textarea.insert_str(ch.to_string().as_str()); - self.sync_popups(); - true - } - FlushResult::None => false, - } - } - - /// Handles keys that mutate the textarea, including paste-burst detection. - /// - /// Acts as the lowest-level keypath for keys that mutate the textarea. It is also where plain - /// character streams are converted into explicit paste operations on terminals that do not - /// reliably provide bracketed paste. - /// - /// Ordering is important: - /// - /// - Always flush any *due* paste burst first so buffered text does not lag behind unrelated - /// edits. - /// - Then handle the incoming key, intercepting only "plain" (no Ctrl/Alt) char input. - /// - For non-plain keys, flush via `flush_before_modified_input()` before applying the key; - /// otherwise `clear_window_after_non_char()` can leave buffered text waiting without a - /// timestamp to time out against. - fn handle_input_basic(&mut self, input: KeyEvent) -> (InputResult, bool) { - self.handle_input_basic_with_time(input, Instant::now()) - } - - fn handle_input_basic_with_time( - &mut self, - input: KeyEvent, - now: Instant, - ) -> (InputResult, bool) { - // If we have a buffered non-bracketed paste burst and enough time has - // elapsed since the last char, flush it before handling a new input. - self.handle_paste_burst_flush(now); - - if !matches!(input.code, KeyCode::Esc) { - self.footer_mode = reset_mode_after_activity(self.footer_mode); - } - - // If we're capturing a burst and receive Enter, accumulate it instead of inserting. - if matches!(input.code, KeyCode::Enter) - && !self.disable_paste_burst - && self.paste_burst.is_active() - && self.paste_burst.append_newline_if_active(now) - { - return (InputResult::None, true); - } - - // Intercept plain Char inputs to optionally accumulate into a burst buffer. - // - // This is intentionally limited to "plain" (no Ctrl/Alt) chars so shortcuts keep their - // normal semantics, and so we can aggressively flush/clear any burst state when non-char - // keys are pressed. - if let KeyEvent { - code: KeyCode::Char(ch), - modifiers, - .. - } = input - { - let has_ctrl_or_alt = has_ctrl_or_alt(modifiers); - if !has_ctrl_or_alt && !self.disable_paste_burst { - // Non-ASCII characters (e.g., from IMEs) can arrive in quick bursts, so avoid - // holding the first char while still allowing burst detection for paste input. - if !ch.is_ascii() { - return self.handle_non_ascii_char(input, now); - } - - match self.paste_burst.on_plain_char(ch, now) { - CharDecision::BufferAppend => { - self.paste_burst.append_char_to_buffer(ch, now); - return (InputResult::None, true); - } - CharDecision::BeginBuffer { retro_chars } => { - let cur = self.textarea.cursor(); - let txt = self.textarea.text(); - let safe_cur = Self::clamp_to_char_boundary(txt, cur); - let before = &txt[..safe_cur]; - if let Some(grab) = - self.paste_burst - .decide_begin_buffer(now, before, retro_chars as usize) - { - if !grab.grabbed.is_empty() { - self.textarea.replace_range(grab.start_byte..safe_cur, ""); - } - self.paste_burst.append_char_to_buffer(ch, now); - return (InputResult::None, true); - } - // If decide_begin_buffer opted not to start buffering, - // fall through to normal insertion below. - } - CharDecision::BeginBufferFromPending => { - // First char was held; now append the current one. - self.paste_burst.append_char_to_buffer(ch, now); - return (InputResult::None, true); - } - CharDecision::RetainFirstChar => { - // Keep the first fast char pending momentarily. - return (InputResult::None, true); - } - } - } - if let Some(pasted) = self.paste_burst.flush_before_modified_input() { - self.handle_paste(pasted); - } - } - - // Flush any buffered burst before applying a non-char input (arrow keys, etc). - // - // `clear_window_after_non_char()` clears `last_plain_char_time`. If we cleared that while - // `PasteBurst.buffer` is non-empty, `flush_if_due()` would no longer have a timestamp to - // time out against, and the buffered paste could remain stuck until another plain char - // arrives. - if !matches!(input.code, KeyCode::Char(_) | KeyCode::Enter) - && let Some(pasted) = self.paste_burst.flush_before_modified_input() - { - self.handle_paste(pasted); - } - - // Backspace at the start of an image placeholder should delete that placeholder (rather - // than deleting content before it). Do this without scanning the full text by consulting - // the textarea's element list. - if matches!(input.code, KeyCode::Backspace) - && self.try_remove_image_element_at_cursor_start() - { - return (InputResult::None, true); - } - - // Track element removals so we can drop any corresponding placeholders without scanning - // the full text. (Placeholders are atomic elements; when deleted, the element disappears.) - let elements_before = if self.pending_pastes.is_empty() && self.attached_images.is_empty() { - None - } else { - Some(self.textarea.element_payloads()) - }; - - self.textarea.input(input); - - if let Some(elements_before) = elements_before { - self.reconcile_deleted_elements(elements_before); - } - - // Update paste-burst heuristic for plain Char (no Ctrl/Alt) events. - let crossterm::event::KeyEvent { - code, modifiers, .. - } = input; - match code { - KeyCode::Char(_) => { - let has_ctrl_or_alt = has_ctrl_or_alt(modifiers); - if has_ctrl_or_alt { - self.paste_burst.clear_window_after_non_char(); - } - } - KeyCode::Enter => { - // Keep burst window alive (supports blank lines in paste). - } - _ => { - // Other keys: clear burst window (buffer should have been flushed above if needed). - self.paste_burst.clear_window_after_non_char(); - } - } - - (InputResult::None, true) - } - - fn try_remove_image_element_at_cursor_start(&mut self) -> bool { - if self.attached_images.is_empty() { - return false; - } - - let p = self.textarea.cursor(); - let Some(payload) = self.textarea.element_payload_starting_at(p) else { - return false; - }; - let Some(idx) = self - .attached_images - .iter() - .position(|img| img.placeholder == payload) - else { - return false; - }; - - self.textarea.replace_range(p..p + payload.len(), ""); - self.attached_images.remove(idx); - self.relabel_attached_images_and_update_placeholders(); - true - } - - fn reconcile_deleted_elements(&mut self, elements_before: Vec) { - let elements_after: HashSet = - self.textarea.element_payloads().into_iter().collect(); - - let mut removed_any_image = false; - for removed in elements_before - .into_iter() - .filter(|payload| !elements_after.contains(payload)) - { - self.pending_pastes.retain(|(ph, _)| ph != &removed); - - if let Some(idx) = self - .attached_images - .iter() - .position(|img| img.placeholder == removed) - { - self.attached_images.remove(idx); - removed_any_image = true; - } - } - - if removed_any_image { - self.relabel_attached_images_and_update_placeholders(); - } - } - - fn relabel_attached_images_and_update_placeholders(&mut self) { - // Renumber by insertion order (attachment list order), and update any matching elements - // regardless of where they appear in the text. - for idx in 0..self.attached_images.len() { - let expected = local_image_label_text(idx + 1); - let current = self.attached_images[idx].placeholder.clone(); - if current == expected { - continue; - } - - self.attached_images[idx].placeholder = expected.clone(); - let _renamed = self.textarea.replace_element_payload(¤t, &expected); - } - } - - fn handle_shortcut_overlay_key(&mut self, key_event: &KeyEvent) -> bool { - if key_event.kind != KeyEventKind::Press { - return false; - } - - let toggles = matches!(key_event.code, KeyCode::Char('?')) - && !has_ctrl_or_alt(key_event.modifiers) - && self.is_empty() - && !self.is_in_paste_burst(); - - if !toggles { - return false; - } - - let next = toggle_shortcut_mode(self.footer_mode, self.quit_shortcut_hint_visible()); - let changed = next != self.footer_mode; - self.footer_mode = next; - changed - } - - fn footer_props(&self) -> FooterProps { - FooterProps { - mode: self.footer_mode(), - esc_backtrack_hint: self.esc_backtrack_hint, - use_shift_enter_hint: self.use_shift_enter_hint, - is_task_running: self.is_task_running, - quit_shortcut_key: self.quit_shortcut_key, - steer_enabled: self.steer_enabled, - collaboration_modes_enabled: self.collaboration_modes_enabled, - context_window_percent: self.context_window_percent, - context_window_used_tokens: self.context_window_used_tokens, - transcript_scrolled: self.transcript_scrolled, - transcript_selection_active: self.transcript_selection_active, - transcript_scroll_position: self.transcript_scroll_position, - transcript_copy_selection_key: self.transcript_copy_selection_key, - transcript_copy_feedback: self.transcript_copy_feedback, - } - } - - fn footer_mode(&self) -> FooterMode { - match self.footer_mode { - FooterMode::EscHint => FooterMode::EscHint, - FooterMode::ShortcutOverlay => FooterMode::ShortcutOverlay, - FooterMode::QuitShortcutReminder if self.quit_shortcut_hint_visible() => { - FooterMode::QuitShortcutReminder - } - FooterMode::QuitShortcutReminder => FooterMode::ShortcutSummary, - FooterMode::ShortcutSummary if self.quit_shortcut_hint_visible() => { - FooterMode::QuitShortcutReminder - } - FooterMode::ShortcutSummary if !self.is_empty() => FooterMode::ContextOnly, - other => other, - } - } - - fn custom_footer_height(&self) -> Option { - if self.footer_flash_visible() { - return Some(1); - } - self.footer_hint_override - .as_ref() - .map(|items| if items.is_empty() { 0 } else { 1 }) - } - - /// Update the footer's view of transcript scroll state for the inline viewport. - /// - /// This state is derived from the main `App`'s transcript viewport and passed - /// through the bottom pane so the footer can indicate when the transcript is - /// scrolled away from the bottom, whether a selection is active, and the - /// current `(visible_top, total)` position. - pub(crate) fn set_transcript_ui_state( - &mut self, - scrolled: bool, - selection_active: bool, - scroll_position: Option<(usize, usize)>, - copy_selection_key: KeyBinding, - copy_feedback: Option, - ) -> bool { - if self.transcript_scrolled == scrolled - && self.transcript_selection_active == selection_active - && self.transcript_scroll_position == scroll_position - && self.transcript_copy_selection_key == copy_selection_key - && self.transcript_copy_feedback == copy_feedback - { - return false; - } - - self.transcript_scrolled = scrolled; - self.transcript_selection_active = selection_active; - self.transcript_scroll_position = scroll_position; - self.transcript_copy_selection_key = copy_selection_key; - self.transcript_copy_feedback = copy_feedback; - true - } - - fn sync_popups(&mut self) { - let file_token = Self::current_at_token(&self.textarea); - let browsing_history = self - .history - .should_handle_navigation(self.textarea.text(), self.textarea.cursor()); - // When browsing input history (shell-style Up/Down recall), skip all popup - // synchronization so nothing steals focus from continued history navigation. - if browsing_history { - self.active_popup = ActivePopup::None; - return; - } - let skill_token = self.current_skill_token(); - - let allow_command_popup = file_token.is_none() && skill_token.is_none(); - self.sync_command_popup(allow_command_popup); - - if matches!(self.active_popup, ActivePopup::Command(_)) { - self.dismissed_file_popup_token = None; - self.dismissed_skill_popup_token = None; - return; - } - - if let Some(token) = skill_token { - self.sync_skill_popup(token); - return; - } - self.dismissed_skill_popup_token = None; - - if let Some(token) = file_token { - self.sync_file_search_popup(token); - return; - } - - self.dismissed_file_popup_token = None; - if matches!( - self.active_popup, - ActivePopup::File(_) | ActivePopup::Skill(_) - ) { - self.active_popup = ActivePopup::None; - } - } - - /// If the cursor is currently within a slash command on the first line, - /// extract the command name and the rest of the line after it. - /// Returns None if the cursor is outside a slash command. - fn slash_command_under_cursor(first_line: &str, cursor: usize) -> Option<(&str, &str)> { - if !first_line.starts_with('/') { - return None; - } - - let name_start = 1usize; - let name_end = first_line[name_start..] - .find(char::is_whitespace) - .map(|idx| name_start + idx) - .unwrap_or_else(|| first_line.len()); - - if cursor > name_end { - return None; - } - - let name = &first_line[name_start..name_end]; - let rest_start = first_line[name_end..] - .find(|c: char| !c.is_whitespace()) - .map(|idx| name_end + idx) - .unwrap_or(name_end); - let rest = &first_line[rest_start..]; - - Some((name, rest)) - } - - /// Heuristic for whether the typed slash command looks like a valid - /// prefix for any known command (built-in or custom prompt). - /// Empty names only count when there is no extra content after the '/'. - fn looks_like_slash_prefix(&self, name: &str, rest_after_name: &str) -> bool { - if name.is_empty() { - return rest_after_name.is_empty(); - } - - let builtin_match = - Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled) - .any(|(cmd_name, _)| fuzzy_match(cmd_name, name).is_some()); - - if builtin_match { - return true; - } - - let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:"); - self.custom_prompts - .iter() - .any(|p| fuzzy_match(&format!("{prompt_prefix}{}", p.name), name).is_some()) - } - - /// Synchronize `self.command_popup` with the current text in the - /// textarea. This must be called after every modification that can change - /// the text so the popup is shown/updated/hidden as appropriate. - fn sync_command_popup(&mut self, allow: bool) { - if !allow { - if matches!(self.active_popup, ActivePopup::Command(_)) { - self.active_popup = ActivePopup::None; - } - return; - } - // Determine whether the caret is inside the initial '/name' token on the first line. - let text = self.textarea.text(); - let first_line_end = text.find('\n').unwrap_or(text.len()); - let first_line = &text[..first_line_end]; - let cursor = self.textarea.cursor(); - let caret_on_first_line = cursor <= first_line_end; - - let is_editing_slash_command_name = caret_on_first_line - && Self::slash_command_under_cursor(first_line, cursor) - .is_some_and(|(name, rest)| self.looks_like_slash_prefix(name, rest)); - - // If the cursor is currently positioned within an `@token`, prefer the - // file-search popup over the slash popup so users can insert a file path - // as an argument to the command (e.g., "/review @docs/..."). - if Self::current_at_token(&self.textarea).is_some() { - if matches!(self.active_popup, ActivePopup::Command(_)) { - self.active_popup = ActivePopup::None; - } - return; - } - match &mut self.active_popup { - ActivePopup::Command(popup) => { - if is_editing_slash_command_name { - popup.on_composer_text_change(first_line.to_string()); - } else { - self.active_popup = ActivePopup::None; - } - } - _ => { - if is_editing_slash_command_name { - let skills_enabled = self.skills_enabled(); - let collaboration_modes_enabled = self.collaboration_modes_enabled; - let mut command_popup = CommandPopup::new( - self.custom_prompts.clone(), - CommandPopupFlags { - skills_enabled, - collaboration_modes_enabled, - }, - ); - command_popup.on_composer_text_change(first_line.to_string()); - self.active_popup = ActivePopup::Command(command_popup); - } - } - } - } - - fn built_in_slash_commands_for_input( - collaboration_modes_enabled: bool, - ) -> impl Iterator { - let allow_elevate_sandbox = windows_degraded_sandbox_active(); - built_in_slash_commands() - .into_iter() - .filter(move |(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox) - .filter(move |(_, cmd)| collaboration_modes_enabled || *cmd != SlashCommand::Collab) - } - - pub(crate) fn set_custom_prompts(&mut self, prompts: Vec) { - self.custom_prompts = prompts.clone(); - if let ActivePopup::Command(popup) = &mut self.active_popup { - popup.set_prompts(prompts); - } - } - - /// Synchronize `self.file_search_popup` with the current text in the textarea. - /// Note this is only called when self.active_popup is NOT Command. - fn sync_file_search_popup(&mut self, query: String) { - // If user dismissed popup for this exact query, don't reopen until text changes. - if self.dismissed_file_popup_token.as_ref() == Some(&query) { - return; - } - - if !query.is_empty() { - self.app_event_tx - .send(AppEvent::StartFileSearch(query.clone())); - } - - match &mut self.active_popup { - ActivePopup::File(popup) => { - if query.is_empty() { - popup.set_empty_prompt(); - } else { - popup.set_query(&query); - } - } - _ => { - let mut popup = FileSearchPopup::new(); - if query.is_empty() { - popup.set_empty_prompt(); - } else { - popup.set_query(&query); - } - self.active_popup = ActivePopup::File(popup); - } - } - - self.current_file_query = Some(query); - self.dismissed_file_popup_token = None; - } - - fn sync_skill_popup(&mut self, query: String) { - if self.dismissed_skill_popup_token.as_ref() == Some(&query) { - return; - } - - let skills = match self.skills.as_ref() { - Some(skills) if !skills.is_empty() => skills.clone(), - _ => { - self.active_popup = ActivePopup::None; - return; - } - }; - - match &mut self.active_popup { - ActivePopup::Skill(popup) => { - popup.set_query(&query); - popup.set_skills(skills); - } - _ => { - let mut popup = SkillPopup::new(skills); - popup.set_query(&query); - self.active_popup = ActivePopup::Skill(popup); - } - } - } - - fn set_has_focus(&mut self, has_focus: bool) { - self.has_focus = has_focus; - } - - #[allow(dead_code)] - pub(crate) fn set_input_enabled(&mut self, enabled: bool, placeholder: Option) { - self.input_enabled = enabled; - self.input_disabled_placeholder = if enabled { None } else { placeholder }; - - // Avoid leaving interactive popups open while input is blocked. - if !enabled && !matches!(self.active_popup, ActivePopup::None) { - self.active_popup = ActivePopup::None; - } - } - - pub fn set_task_running(&mut self, running: bool) { - self.is_task_running = running; - } - - pub(crate) fn set_context_window(&mut self, percent: Option, used_tokens: Option) { - if self.context_window_percent == percent && self.context_window_used_tokens == used_tokens - { - return; - } - self.context_window_percent = percent; - self.context_window_used_tokens = used_tokens; - } - - pub(crate) fn set_esc_backtrack_hint(&mut self, show: bool) { - self.esc_backtrack_hint = show; - if show { - self.footer_mode = esc_hint_mode(self.footer_mode, self.is_task_running); - } else { - self.footer_mode = reset_mode_after_activity(self.footer_mode); - } - } -} - -impl Renderable for ChatComposer { - fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { - if !self.input_enabled { - return None; - } - - let [_, textarea_rect, _] = self.layout_areas(area); - let state = *self.textarea_state.borrow(); - self.textarea.cursor_pos_with_state(textarea_rect, state) - } - - fn desired_height(&self, width: u16) -> u16 { - let footer_props = self.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; - const COLS_WITH_MARGIN: u16 = LIVE_PREFIX_COLS + 1; - self.textarea - .desired_height(width.saturating_sub(COLS_WITH_MARGIN)) - + 2 - + match &self.active_popup { - ActivePopup::None => footer_total_height, - ActivePopup::Command(c) => c.calculate_required_height(width), - ActivePopup::File(c) => c.calculate_required_height(), - ActivePopup::Skill(c) => c.calculate_required_height(width), - } - } - - fn render(&self, area: Rect, buf: &mut Buffer) { - let [composer_rect, textarea_rect, popup_rect] = self.layout_areas(area); - match &self.active_popup { - ActivePopup::Command(popup) => { - popup.render_ref(popup_rect, buf); - } - ActivePopup::File(popup) => { - popup.render_ref(popup_rect, buf); - } - ActivePopup::Skill(popup) => { - popup.render_ref(popup_rect, buf); - } - ActivePopup::None => { - let footer_props = self.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([ - Constraint::Length(footer_spacing), - Constraint::Length(footer_hint_height), - ]) - .areas(popup_rect); - hint_rect - } else { - popup_rect - }; - if self.footer_flash_visible() { - if let Some(flash) = self.footer_flash.as_ref() { - flash.line.render(inset_footer_hint_area(hint_rect), buf); - } - } else if let Some(items) = self.footer_hint_override.as_ref() { - render_footer_hint_items(hint_rect, buf, items); - } else { - render_footer(hint_rect, buf, footer_props); - } - } - } - let style = user_message_style(); - Block::default().style(style).render_ref(composer_rect, buf); - if !textarea_rect.is_empty() { - let prompt = if self.input_enabled { - "›".bold() - } else { - "›".dim() - }; - buf.set_span( - textarea_rect.x - LIVE_PREFIX_COLS, - textarea_rect.y, - &prompt, - textarea_rect.width, - ); - } - - let mut state = self.textarea_state.borrow_mut(); - StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state); - if self.textarea.text().is_empty() { - let text = if self.input_enabled { - self.placeholder_text.as_str().to_string() - } else { - self.input_disabled_placeholder - .as_deref() - .unwrap_or("Input disabled.") - .to_string() - }; - let placeholder = Span::from(text).dim(); - Line::from(vec![placeholder]).render_ref(textarea_rect.inner(Margin::new(0, 0)), buf); - } - } -} - -fn prompt_selection_action( - prompt: &CustomPrompt, - first_line: &str, - mode: PromptSelectionMode, - text_elements: &[TextElement], -) -> PromptSelectionAction { - let named_args = prompt_argument_names(&prompt.content); - let has_numeric = prompt_has_numeric_placeholders(&prompt.content); - - match mode { - PromptSelectionMode::Completion => { - if !named_args.is_empty() { - let (text, cursor) = - prompt_command_with_arg_placeholders(&prompt.name, &named_args); - return PromptSelectionAction::Insert { - text, - cursor: Some(cursor), - }; - } - if has_numeric { - let text = format!("/{PROMPTS_CMD_PREFIX}:{} ", prompt.name); - return PromptSelectionAction::Insert { text, cursor: None }; - } - let text = format!("/{PROMPTS_CMD_PREFIX}:{}", prompt.name); - PromptSelectionAction::Insert { text, cursor: None } - } - PromptSelectionMode::Submit => { - if !named_args.is_empty() { - let (text, cursor) = - prompt_command_with_arg_placeholders(&prompt.name, &named_args); - return PromptSelectionAction::Insert { - text, - cursor: Some(cursor), - }; - } - if has_numeric { - if let Some(expanded) = - expand_if_numeric_with_positional_args(prompt, first_line, text_elements) - { - return PromptSelectionAction::Submit { - text: expanded.text, - text_elements: expanded.text_elements, - }; - } - let text = format!("/{PROMPTS_CMD_PREFIX}:{} ", prompt.name); - return PromptSelectionAction::Insert { text, cursor: None }; - } - PromptSelectionAction::Submit { - text: prompt.content.clone(), - // By now we know this custom prompt has no args, so no text elements to preserve. - text_elements: Vec::new(), - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use image::ImageBuffer; - use image::Rgba; - use pretty_assertions::assert_eq; - use std::path::PathBuf; - use tempfile::tempdir; - - use crate::app_event::AppEvent; - use crate::bottom_pane::AppEventSender; - use crate::bottom_pane::ChatComposer; - use crate::bottom_pane::InputResult; - use crate::bottom_pane::chat_composer::AttachedImage; - use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD; - use crate::bottom_pane::prompt_args::PromptArg; - use crate::bottom_pane::prompt_args::extract_positional_args_for_prompt_line; - use crate::bottom_pane::textarea::TextArea; - use tokio::sync::mpsc::unbounded_channel; - - #[test] - fn footer_hint_row_is_separated_from_composer() { - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - let area = Rect::new(0, 0, 40, 6); - let mut buf = Buffer::empty(area); - composer.render(area, &mut buf); - - let row_to_string = |y: u16| { - let mut row = String::new(); - for x in 0..area.width { - row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); - } - row - }; - - let mut hint_row: Option<(u16, String)> = None; - for y in 0..area.height { - let row = row_to_string(y); - if row.contains("? for shortcuts") { - hint_row = Some((y, row)); - break; - } - } - - let (hint_row_idx, hint_row_contents) = - hint_row.expect("expected footer hint row to be rendered"); - assert_eq!( - hint_row_idx, - area.height - 1, - "hint row should occupy the bottom line: {hint_row_contents:?}", - ); - - assert!( - hint_row_idx > 0, - "expected a spacing row above the footer hints", - ); - - let spacing_row = row_to_string(hint_row_idx - 1); - assert_eq!( - spacing_row.trim(), - "", - "expected blank spacing row above hints but saw: {spacing_row:?}", - ); - } - - #[test] - fn footer_flash_overrides_footer_hint_override() { - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_footer_hint_override(Some(vec![("K".to_string(), "label".to_string())])); - composer.show_footer_flash(Line::from("FLASH"), Duration::from_secs(10)); - - let area = Rect::new(0, 0, 60, 6); - let mut buf = Buffer::empty(area); - composer.render(area, &mut buf); - - let mut bottom_row = String::new(); - for x in 0..area.width { - bottom_row.push( - buf[(x, area.height - 1)] - .symbol() - .chars() - .next() - .unwrap_or(' '), - ); - } - assert!( - bottom_row.contains("FLASH"), - "expected flash content to render in footer row, saw: {bottom_row:?}", - ); - assert!( - !bottom_row.contains("K label"), - "expected flash to override hint override, saw: {bottom_row:?}", - ); - } - - #[test] - fn footer_flash_expires_and_falls_back_to_hint_override() { - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_footer_hint_override(Some(vec![("K".to_string(), "label".to_string())])); - composer.show_footer_flash(Line::from("FLASH"), Duration::from_secs(10)); - composer.footer_flash.as_mut().unwrap().expires_at = - Instant::now() - Duration::from_secs(1); - - let area = Rect::new(0, 0, 60, 6); - let mut buf = Buffer::empty(area); - composer.render(area, &mut buf); - - let mut bottom_row = String::new(); - for x in 0..area.width { - bottom_row.push( - buf[(x, area.height - 1)] - .symbol() - .chars() - .next() - .unwrap_or(' '), - ); - } - assert!( - bottom_row.contains("K label"), - "expected hint override to render after flash expired, saw: {bottom_row:?}", - ); - assert!( - !bottom_row.contains("FLASH"), - "expected expired flash to be hidden, saw: {bottom_row:?}", - ); - } - - fn snapshot_composer_state(name: &str, enhanced_keys_supported: bool, setup: F) - where - F: FnOnce(&mut ChatComposer), - { - use ratatui::Terminal; - use ratatui::backend::TestBackend; - - let width = 100; - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - enhanced_keys_supported, - "Ask Codex to do anything".to_string(), - false, - ); - setup(&mut composer); - let footer_props = composer.footer_props(); - let footer_lines = footer_height(footer_props); - let footer_spacing = ChatComposer::footer_spacing(footer_lines); - let height = footer_lines + footer_spacing + 8; - let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); - terminal - .draw(|f| composer.render(f.area(), f.buffer_mut())) - .unwrap(); - insta::assert_snapshot!(name, terminal.backend()); - } - - #[test] - fn footer_mode_snapshots() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - snapshot_composer_state("footer_mode_shortcut_overlay", true, |composer| { - composer.set_esc_backtrack_hint(true); - let _ = - composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); - }); - - snapshot_composer_state("footer_mode_ctrl_c_quit", true, |composer| { - composer.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')), true); - }); - - snapshot_composer_state("footer_mode_ctrl_c_interrupt", true, |composer| { - composer.set_task_running(true); - composer.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')), true); - }); - - snapshot_composer_state("footer_mode_ctrl_c_then_esc_hint", true, |composer| { - composer.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')), true); - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - }); - - snapshot_composer_state("footer_mode_esc_hint_from_overlay", true, |composer| { - let _ = - composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - }); - - snapshot_composer_state("footer_mode_esc_hint_backtrack", true, |composer| { - composer.set_esc_backtrack_hint(true); - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - }); - - snapshot_composer_state( - "footer_mode_overlay_then_external_esc_hint", - true, - |composer| { - let _ = composer - .handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); - composer.set_esc_backtrack_hint(true); - }, - ); - - snapshot_composer_state("footer_mode_hidden_while_typing", true, |composer| { - type_chars_humanlike(composer, &['h']); - }); - } - - #[test] - fn esc_hint_stays_hidden_with_draft_content() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - true, - "Ask Codex to do anything".to_string(), - false, - ); - - type_chars_humanlike(&mut composer, &['d']); - - assert!(!composer.is_empty()); - assert_eq!(composer.current_text(), "d"); - assert_eq!(composer.footer_mode, FooterMode::ShortcutSummary); - assert!(matches!(composer.active_popup, ActivePopup::None)); - - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - - assert_eq!(composer.footer_mode, FooterMode::ShortcutSummary); - assert!(!composer.esc_backtrack_hint); - } - - #[test] - fn clear_for_ctrl_c_records_cleared_draft() { - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - composer.set_text_content("draft text".to_string(), Vec::new(), Vec::new()); - assert_eq!(composer.clear_for_ctrl_c(), Some("draft text".to_string())); - assert!(composer.is_empty()); - - assert_eq!( - composer.history.navigate_up(&composer.app_event_tx), - Some("draft text".to_string()) - ); - } - - /// Behavior: `?` toggles the shortcut overlay only when the composer is otherwise empty. After - /// any typing has occurred, `?` should be inserted as a literal character. - #[test] - fn question_mark_only_toggles_on_first_char() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - let (result, needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); - assert_eq!(result, InputResult::None); - assert!(needs_redraw, "toggling overlay should request redraw"); - assert_eq!(composer.footer_mode, FooterMode::ShortcutOverlay); - - // Toggle back to prompt mode so subsequent typing captures characters. - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); - assert_eq!(composer.footer_mode, FooterMode::ShortcutSummary); - - type_chars_humanlike(&mut composer, &['h']); - assert_eq!(composer.textarea.text(), "h"); - assert_eq!(composer.footer_mode(), FooterMode::ContextOnly); - - let (result, needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); - assert_eq!(result, InputResult::None); - assert!(needs_redraw, "typing should still mark the view dirty"); - let _ = flush_after_paste_burst(&mut composer); - assert_eq!(composer.textarea.text(), "h?"); - assert_eq!(composer.footer_mode, FooterMode::ShortcutSummary); - assert_eq!(composer.footer_mode(), FooterMode::ContextOnly); - } - - /// Behavior: while a paste-like burst is being captured, `?` must not toggle the shortcut - /// overlay; it should be treated as part of the pasted content. - #[test] - fn question_mark_does_not_toggle_during_paste_burst() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - // Force an active paste burst so this test doesn't depend on tight timing. - composer - .paste_burst - .begin_with_retro_grabbed(String::new(), Instant::now()); - - for ch in ['h', 'i', '?', 't', 'h', 'e', 'r', 'e'] { - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); - } - assert!(composer.is_in_paste_burst()); - assert_eq!(composer.textarea.text(), ""); - - let flushed = flush_after_paste_burst(&mut composer); - assert!(flushed, "expected buffered text to flush after stop"); - - assert_eq!(composer.textarea.text(), "hi?there"); - assert_ne!(composer.footer_mode, FooterMode::ShortcutOverlay); - } - - #[test] - fn shortcut_overlay_persists_while_task_running() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); - assert_eq!(composer.footer_mode, FooterMode::ShortcutOverlay); - - composer.set_task_running(true); - - assert_eq!(composer.footer_mode, FooterMode::ShortcutOverlay); - assert_eq!(composer.footer_mode(), FooterMode::ShortcutOverlay); - } - - #[test] - fn test_current_at_token_basic_cases() { - let test_cases = vec![ - // Valid @ tokens - ("@hello", 3, Some("hello".to_string()), "Basic ASCII token"), - ( - "@file.txt", - 4, - Some("file.txt".to_string()), - "ASCII with extension", - ), - ( - "hello @world test", - 8, - Some("world".to_string()), - "ASCII token in middle", - ), - ( - "@test123", - 5, - Some("test123".to_string()), - "ASCII with numbers", - ), - // Unicode examples - ("@İstanbul", 3, Some("İstanbul".to_string()), "Turkish text"), - ( - "@testЙЦУ.rs", - 8, - Some("testЙЦУ.rs".to_string()), - "Mixed ASCII and Cyrillic", - ), - ("@诶", 2, Some("诶".to_string()), "Chinese character"), - ("@👍", 2, Some("👍".to_string()), "Emoji token"), - // Invalid cases (should return None) - ("hello", 2, None, "No @ symbol"), - ( - "@", - 1, - Some("".to_string()), - "Only @ symbol triggers empty query", - ), - ("@ hello", 2, None, "@ followed by space"), - ("test @ world", 6, None, "@ with spaces around"), - ]; - - for (input, cursor_pos, expected, description) in test_cases { - let mut textarea = TextArea::new(); - textarea.insert_str(input); - textarea.set_cursor(cursor_pos); - - let result = ChatComposer::current_at_token(&textarea); - assert_eq!( - result, expected, - "Failed for case: {description} - input: '{input}', cursor: {cursor_pos}" - ); - } - } - - #[test] - fn test_current_at_token_cursor_positions() { - let test_cases = vec![ - // Different cursor positions within a token - ("@test", 0, Some("test".to_string()), "Cursor at @"), - ("@test", 1, Some("test".to_string()), "Cursor after @"), - ("@test", 5, Some("test".to_string()), "Cursor at end"), - // Multiple tokens - cursor determines which token - ("@file1 @file2", 0, Some("file1".to_string()), "First token"), - ( - "@file1 @file2", - 8, - Some("file2".to_string()), - "Second token", - ), - // Edge cases - ("@", 0, Some("".to_string()), "Only @ symbol"), - ("@a", 2, Some("a".to_string()), "Single character after @"), - ("", 0, None, "Empty input"), - ]; - - for (input, cursor_pos, expected, description) in test_cases { - let mut textarea = TextArea::new(); - textarea.insert_str(input); - textarea.set_cursor(cursor_pos); - - let result = ChatComposer::current_at_token(&textarea); - assert_eq!( - result, expected, - "Failed for cursor position case: {description} - input: '{input}', cursor: {cursor_pos}", - ); - } - } - - #[test] - fn test_current_at_token_whitespace_boundaries() { - let test_cases = vec![ - // Space boundaries - ( - "aaa@aaa", - 4, - None, - "Connected @ token - no completion by design", - ), - ( - "aaa @aaa", - 5, - Some("aaa".to_string()), - "@ token after space", - ), - ( - "test @file.txt", - 7, - Some("file.txt".to_string()), - "@ token after space", - ), - // Full-width space boundaries - ( - "test @İstanbul", - 8, - Some("İstanbul".to_string()), - "@ token after full-width space", - ), - ( - "@ЙЦУ @诶", - 10, - Some("诶".to_string()), - "Full-width space between Unicode tokens", - ), - // Tab and newline boundaries - ( - "test\t@file", - 6, - Some("file".to_string()), - "@ token after tab", - ), - ]; - - for (input, cursor_pos, expected, description) in test_cases { - let mut textarea = TextArea::new(); - textarea.insert_str(input); - textarea.set_cursor(cursor_pos); - - let result = ChatComposer::current_at_token(&textarea); - assert_eq!( - result, expected, - "Failed for whitespace boundary case: {description} - input: '{input}', cursor: {cursor_pos}", - ); - } - } - - /// Behavior: if the ASCII path has a pending first char (flicker suppression) and a non-ASCII - /// char arrives next, the pending ASCII char should still be preserved and the overall input - /// should submit normally (i.e. we should not misclassify this as a paste burst). - #[test] - fn ascii_prefix_survives_non_ascii_followup() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)); - assert!(composer.is_in_paste_burst()); - - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('あ'), KeyModifiers::NONE)); - - let (result, _) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - match result { - InputResult::Submitted { text, .. } => assert_eq!(text, "1あ"), - _ => panic!("expected Submitted"), - } - } - - /// Behavior: while we're capturing a paste-like burst, Enter should be treated as a newline - /// within the burst (not as "submit"), and the whole payload should flush as one paste. - #[test] - fn non_ascii_burst_buffers_enter_and_flushes_multiline() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - composer - .paste_burst - .begin_with_retro_grabbed(String::new(), Instant::now()); - - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('你'), KeyModifiers::NONE)); - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('好'), KeyModifiers::NONE)); - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)); - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); - - assert!(composer.textarea.text().is_empty()); - let _ = flush_after_paste_burst(&mut composer); - assert_eq!(composer.textarea.text(), "你好\nhi"); - } - - /// Behavior: a paste-like burst may include a full-width/ideographic space (U+3000). It should - /// still be captured as a single paste payload and preserve the exact Unicode content. - #[test] - fn non_ascii_burst_preserves_ideographic_space_and_ascii() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - composer - .paste_burst - .begin_with_retro_grabbed(String::new(), Instant::now()); - - for ch in ['你', ' ', '好'] { - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); - } - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - for ch in ['h', 'i'] { - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); - } - - assert!(composer.textarea.text().is_empty()); - let _ = flush_after_paste_burst(&mut composer); - assert_eq!(composer.textarea.text(), "你 好\nhi"); - } - - /// Behavior: a large multi-line payload containing both non-ASCII and ASCII (e.g. "UTF-8", - /// "Unicode") should be captured as a single paste-like burst, and Enter key events should - /// become `\n` within the buffered content. - #[test] - fn non_ascii_burst_buffers_large_multiline_mixed_ascii_and_unicode() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - const LARGE_MIXED_PAYLOAD: &str = "天地玄黄 宇宙洪荒\n\ -日月盈昃 辰宿列张\n\ -寒来暑往 秋收冬藏\n\ -\n\ -你好世界 编码测试\n\ -汉字处理 UTF-8\n\ -终端显示 正确无误\n\ -\n\ -风吹竹林 月照大江\n\ -白云千载 青山依旧\n\ -程序员 与 Unicode 同行"; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - // Force an active burst so the test doesn't depend on timing heuristics. - composer - .paste_burst - .begin_with_retro_grabbed(String::new(), Instant::now()); - - for ch in LARGE_MIXED_PAYLOAD.chars() { - let code = if ch == '\n' { - KeyCode::Enter - } else { - KeyCode::Char(ch) - }; - let _ = composer.handle_key_event(KeyEvent::new(code, KeyModifiers::NONE)); - } - - assert!(composer.textarea.text().is_empty()); - let _ = flush_after_paste_burst(&mut composer); - assert_eq!(composer.textarea.text(), LARGE_MIXED_PAYLOAD); - } - - /// Behavior: a single non-ASCII char should be inserted immediately (IME-friendly) and should - /// not create any paste-burst state. - #[test] - fn non_ascii_char_inserts_immediately_without_burst_state() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('あ'), KeyModifiers::NONE)); - - assert_eq!(composer.textarea.text(), "あ"); - assert!(!composer.is_in_paste_burst()); - } - - /// Behavior: a single non-ASCII char should submit normally on Enter (i.e. no burst/newline - /// suppression for the "IME single character" case). - #[test] - fn enter_submits_after_single_non_ascii_char() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('あ'), KeyModifiers::NONE)); - - let (result, _) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - match result { - InputResult::Submitted { text, .. } => assert_eq!(text, "あ"), - _ => panic!("expected Submitted"), - } - } - - /// Behavior: while a paste-like burst is active, Enter should not submit; it should insert a - /// newline into the buffered payload and flush as a single paste later. - #[test] - fn ascii_burst_treats_enter_as_newline() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - let mut now = Instant::now(); - let step = Duration::from_millis(1); - - let _ = composer.handle_input_basic_with_time( - KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE), - now, - ); - now += step; - let _ = composer.handle_input_basic_with_time( - KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE), - now, - ); - now += step; - - let (result, _) = composer.handle_submission_with_time(!composer.steer_enabled, now); - assert!( - matches!(result, InputResult::None), - "Enter during a burst should insert newline, not submit" - ); - - for ch in ['t', 'h', 'e', 'r', 'e'] { - now += step; - let _ = composer.handle_input_basic_with_time( - KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), - now, - ); - } - - assert!(composer.textarea.text().is_empty()); - let flush_time = now + PasteBurst::recommended_active_flush_delay() + step; - let flushed = composer.handle_paste_burst_flush(flush_time); - assert!(flushed, "expected paste burst to flush"); - assert_eq!(composer.textarea.text(), "hi\nthere"); - } - - /// Behavior: even if Enter suppression would normally be active for a burst, Enter should - /// still dispatch a built-in slash command when the first line begins with `/`. - #[test] - fn slash_context_enter_ignores_paste_burst_enter_suppression() { - use crate::slash_command::SlashCommand; - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - composer.textarea.set_text_clearing_elements("/diff"); - composer.textarea.set_cursor("/diff".len()); - composer - .paste_burst - .begin_with_retro_grabbed(String::new(), Instant::now()); - - let (result, _) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert!(matches!(result, InputResult::Command(SlashCommand::Diff))); - } - - /// Behavior: if a burst is buffering text and the user presses a non-char key, flush the - /// buffered burst *before* applying that key so the buffer cannot get stuck. - #[test] - fn non_char_key_flushes_active_burst_before_input() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - // Force an active burst so we can deterministically buffer characters without relying on - // timing. - composer - .paste_burst - .begin_with_retro_grabbed(String::new(), Instant::now()); - - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)); - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); - assert!(composer.textarea.text().is_empty()); - assert!(composer.is_in_paste_burst()); - - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "hi"); - assert_eq!(composer.textarea.cursor(), 1); - assert!(!composer.is_in_paste_burst()); - } - - /// Behavior: enabling `disable_paste_burst` flushes any held first character (flicker - /// suppression) and then inserts subsequent chars immediately without creating burst state. - #[test] - fn disable_paste_burst_flushes_pending_first_char_and_inserts_immediately() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - // First ASCII char is normally held briefly. Flip the config mid-stream and ensure the - // held char is not dropped. - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); - assert!(composer.is_in_paste_burst()); - assert!(composer.textarea.text().is_empty()); - - composer.set_disable_paste_burst(true); - assert_eq!(composer.textarea.text(), "a"); - assert!(!composer.is_in_paste_burst()); - - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "ab"); - assert!(!composer.is_in_paste_burst()); - } - - /// Behavior: when a burst is already active, a non-ASCII char should be captured into the - /// burst buffer via the `try_append_char_if_active` fast-path. - #[test] - fn non_ascii_appends_to_active_burst_buffer() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - // Force an active burst so the non-ASCII char takes the fast-path - // (try_append_char_if_active) into the burst buffer. - composer - .paste_burst - .begin_with_retro_grabbed(String::new(), Instant::now()); - - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)); - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('あ'), KeyModifiers::NONE)); - - assert!(composer.textarea.text().is_empty()); - let _ = flush_after_paste_burst(&mut composer); - assert_eq!(composer.textarea.text(), "1あ"); - } - - /// Behavior: a small explicit paste inserts text directly (no placeholder), and the submitted - /// text matches what is visible in the textarea. - #[test] - fn handle_paste_small_inserts_text() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - let needs_redraw = composer.handle_paste("hello".to_string()); - assert!(needs_redraw); - assert_eq!(composer.textarea.text(), "hello"); - assert!(composer.pending_pastes.is_empty()); - - let (result, _) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - match result { - InputResult::Submitted { text, .. } => assert_eq!(text, "hello"), - _ => panic!("expected Submitted"), - } - } - - #[test] - fn empty_enter_returns_none() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - // Ensure composer is empty and press Enter. - assert!(composer.textarea.text().is_empty()); - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - match result { - InputResult::None => {} - other => panic!("expected None for empty enter, got: {other:?}"), - } - } - - /// Behavior: a large explicit paste inserts a placeholder into the textarea, stores the full - /// content in `pending_pastes`, and expands the placeholder to the full content on submit. - #[test] - fn handle_paste_large_uses_placeholder_and_replaces_on_submit() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - let large = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 10); - let needs_redraw = composer.handle_paste(large.clone()); - assert!(needs_redraw); - let placeholder = format!("[Pasted Content {} chars]", large.chars().count()); - assert_eq!(composer.textarea.text(), placeholder); - assert_eq!(composer.pending_pastes.len(), 1); - assert_eq!(composer.pending_pastes[0].0, placeholder); - assert_eq!(composer.pending_pastes[0].1, large); - - let (result, _) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - match result { - InputResult::Submitted { text, .. } => assert_eq!(text, large), - _ => panic!("expected Submitted"), - } - assert!(composer.pending_pastes.is_empty()); - } - - /// Behavior: editing that removes a paste placeholder should also clear the associated - /// `pending_pastes` entry so it cannot be submitted accidentally. - #[test] - fn edit_clears_pending_paste() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let large = "y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1); - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - composer.handle_paste(large); - assert_eq!(composer.pending_pastes.len(), 1); - - // Any edit that removes the placeholder should clear pending_paste - composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); - assert!(composer.pending_pastes.is_empty()); - } - - #[test] - fn ui_snapshots() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - use ratatui::Terminal; - use ratatui::backend::TestBackend; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut terminal = match Terminal::new(TestBackend::new(100, 10)) { - Ok(t) => t, - Err(e) => panic!("Failed to create terminal: {e}"), - }; - - let test_cases = vec![ - ("empty", None), - ("small", Some("short".to_string())), - ("large", Some("z".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5))), - ("multiple_pastes", None), - ("backspace_after_pastes", None), - ]; - - for (name, input) in test_cases { - // Create a fresh composer for each test case - let mut composer = ChatComposer::new( - true, - sender.clone(), - false, - "Ask Codex to do anything".to_string(), - false, - ); - - if let Some(text) = input { - composer.handle_paste(text); - } else if name == "multiple_pastes" { - // First large paste - composer.handle_paste("x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 3)); - // Second large paste - composer.handle_paste("y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 7)); - // Small paste - composer.handle_paste(" another short paste".to_string()); - } else if name == "backspace_after_pastes" { - // Three large pastes - composer.handle_paste("a".repeat(LARGE_PASTE_CHAR_THRESHOLD + 2)); - composer.handle_paste("b".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4)); - composer.handle_paste("c".repeat(LARGE_PASTE_CHAR_THRESHOLD + 6)); - // Move cursor to end and press backspace - composer.textarea.set_cursor(composer.textarea.text().len()); - composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); - } - - terminal - .draw(|f| composer.render(f.area(), f.buffer_mut())) - .unwrap_or_else(|e| panic!("Failed to draw {name} composer: {e}")); - - insta::assert_snapshot!(name, terminal.backend()); - } - } - - #[test] - fn slash_popup_model_first_for_mo_ui() { - use ratatui::Terminal; - use ratatui::backend::TestBackend; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - // Type "/mo" humanlike so paste-burst doesn’t interfere. - type_chars_humanlike(&mut composer, &['/', 'm', 'o']); - - let mut terminal = match Terminal::new(TestBackend::new(60, 5)) { - Ok(t) => t, - Err(e) => panic!("Failed to create terminal: {e}"), - }; - terminal - .draw(|f| composer.render(f.area(), f.buffer_mut())) - .unwrap_or_else(|e| panic!("Failed to draw composer: {e}")); - - // Visual snapshot should show the slash popup with /model as the first entry. - insta::assert_snapshot!("slash_popup_mo", terminal.backend()); - } - - #[test] - fn slash_popup_model_first_for_mo_logic() { - use super::super::command_popup::CommandItem; - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - type_chars_humanlike(&mut composer, &['/', 'm', 'o']); - - match &composer.active_popup { - ActivePopup::Command(popup) => match popup.selected_item() { - Some(CommandItem::Builtin(cmd)) => { - assert_eq!(cmd.command(), "model") - } - Some(CommandItem::UserPrompt(_)) => { - panic!("unexpected prompt selected for '/mo'") - } - None => panic!("no selected command for '/mo'"), - }, - _ => panic!("slash popup not active after typing '/mo'"), - } - } - - #[test] - fn slash_popup_resume_for_res_ui() { - use ratatui::Terminal; - use ratatui::backend::TestBackend; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - // Type "/res" humanlike so paste-burst doesn’t interfere. - type_chars_humanlike(&mut composer, &['/', 'r', 'e', 's']); - - let mut terminal = Terminal::new(TestBackend::new(60, 6)).expect("terminal"); - terminal - .draw(|f| composer.render(f.area(), f.buffer_mut())) - .expect("draw composer"); - - // Snapshot should show /resume as the first entry for /res. - insta::assert_snapshot!("slash_popup_res", terminal.backend()); - } - - #[test] - fn slash_popup_resume_for_res_logic() { - use super::super::command_popup::CommandItem; - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - type_chars_humanlike(&mut composer, &['/', 'r', 'e', 's']); - - match &composer.active_popup { - ActivePopup::Command(popup) => match popup.selected_item() { - Some(CommandItem::Builtin(cmd)) => { - assert_eq!(cmd.command(), "resume") - } - Some(CommandItem::UserPrompt(_)) => { - panic!("unexpected prompt selected for '/res'") - } - None => panic!("no selected command for '/res'"), - }, - _ => panic!("slash popup not active after typing '/res'"), - } - } - - // Test helper: simulate human typing with a brief delay and flush the paste-burst buffer - fn type_chars_humanlike(composer: &mut ChatComposer, chars: &[char]) { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - for &ch in chars { - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); - std::thread::sleep(ChatComposer::recommended_paste_flush_delay()); - let _ = composer.flush_paste_burst_if_due(); - } - } - - fn flush_after_paste_burst(composer: &mut ChatComposer) -> bool { - std::thread::sleep(PasteBurst::recommended_active_flush_delay()); - composer.flush_paste_burst_if_due() - } - - #[test] - fn slash_init_dispatches_command_and_does_not_submit_literal_text() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - // Type the slash command. - type_chars_humanlike(&mut composer, &['/', 'i', 'n', 'i', 't']); - - // Press Enter to dispatch the selected command. - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - // When a slash command is dispatched, the composer should return a - // Command result (not submit literal text) and clear its textarea. - match result { - InputResult::Command(cmd) => { - assert_eq!(cmd.command(), "init"); - } - InputResult::CommandWithArgs(_, _) => { - panic!("expected command dispatch without args for '/init'") - } - InputResult::Submitted { text, .. } => { - panic!("expected command dispatch, but composer submitted literal text: {text}") - } - InputResult::Queued { .. } => { - panic!("expected command dispatch, but composer queued literal text") - } - InputResult::None => panic!("expected Command result for '/init'"), - } - assert!(composer.textarea.is_empty(), "composer should be cleared"); - } - - #[test] - fn extract_args_supports_quoted_paths_single_arg() { - let args = extract_positional_args_for_prompt_line( - "/prompts:review \"docs/My File.md\"", - "review", - &[], - ); - assert_eq!( - args, - vec![PromptArg { - text: "docs/My File.md".to_string(), - text_elements: Vec::new(), - }] - ); - } - - #[test] - fn extract_args_supports_mixed_quoted_and_unquoted() { - let args = extract_positional_args_for_prompt_line( - "/prompts:cmd \"with spaces\" simple", - "cmd", - &[], - ); - assert_eq!( - args, - vec![ - PromptArg { - text: "with spaces".to_string(), - text_elements: Vec::new(), - }, - PromptArg { - text: "simple".to_string(), - text_elements: Vec::new(), - } - ] - ); - } - - #[test] - fn slash_tab_completion_moves_cursor_to_end() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - type_chars_humanlike(&mut composer, &['/', 'c']); - - let (_result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - - assert_eq!(composer.textarea.text(), "/compact "); - assert_eq!(composer.textarea.cursor(), composer.textarea.text().len()); - } - - #[test] - fn slash_tab_then_enter_dispatches_builtin_command() { - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - // Type a prefix and complete with Tab, which inserts a trailing space - // and moves the cursor beyond the '/name' token (hides the popup). - type_chars_humanlike(&mut composer, &['/', 'd', 'i']); - let (_res, _redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "/diff "); - - // Press Enter: should dispatch the command, not submit literal text. - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - match result { - InputResult::Command(cmd) => assert_eq!(cmd.command(), "diff"), - InputResult::CommandWithArgs(_, _) => { - panic!("expected command dispatch without args for '/diff'") - } - InputResult::Submitted { text, .. } => { - panic!("expected command dispatch after Tab completion, got literal submit: {text}") - } - InputResult::Queued { .. } => { - panic!("expected command dispatch after Tab completion, got literal queue") - } - InputResult::None => panic!("expected Command result for '/diff'"), - } - assert!(composer.textarea.is_empty()); - } - - #[test] - fn slash_mention_dispatches_command_and_inserts_at() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - type_chars_humanlike(&mut composer, &['/', 'm', 'e', 'n', 't', 'i', 'o', 'n']); - - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - match result { - InputResult::Command(cmd) => { - assert_eq!(cmd.command(), "mention"); - } - InputResult::CommandWithArgs(_, _) => { - panic!("expected command dispatch without args for '/mention'") - } - InputResult::Submitted { text, .. } => { - panic!("expected command dispatch, but composer submitted literal text: {text}") - } - InputResult::Queued { .. } => { - panic!("expected command dispatch, but composer queued literal text") - } - InputResult::None => panic!("expected Command result for '/mention'"), - } - assert!(composer.textarea.is_empty(), "composer should be cleared"); - composer.insert_str("@"); - assert_eq!(composer.textarea.text(), "@"); - } - - /// Behavior: multiple paste operations can coexist; placeholders should be expanded to their - /// original content on submission. - #[test] - fn test_multiple_pastes_submission() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - // Define test cases: (paste content, is_large) - let test_cases = [ - ("x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 3), true), - (" and ".to_string(), false), - ("y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 7), true), - ]; - - // Expected states after each paste - let mut expected_text = String::new(); - let mut expected_pending_count = 0; - - // Apply all pastes and build expected state - let states: Vec<_> = test_cases - .iter() - .map(|(content, is_large)| { - composer.handle_paste(content.clone()); - if *is_large { - let placeholder = format!("[Pasted Content {} chars]", content.chars().count()); - expected_text.push_str(&placeholder); - expected_pending_count += 1; - } else { - expected_text.push_str(content); - } - (expected_text.clone(), expected_pending_count) - }) - .collect(); - - // Verify all intermediate states were correct - assert_eq!( - states, - vec![ - ( - format!("[Pasted Content {} chars]", test_cases[0].0.chars().count()), - 1 - ), - ( - format!( - "[Pasted Content {} chars] and ", - test_cases[0].0.chars().count() - ), - 1 - ), - ( - format!( - "[Pasted Content {} chars] and [Pasted Content {} chars]", - test_cases[0].0.chars().count(), - test_cases[2].0.chars().count() - ), - 2 - ), - ] - ); - - // Submit and verify final expansion - let (result, _) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - if let InputResult::Submitted { text, .. } = result { - assert_eq!(text, format!("{} and {}", test_cases[0].0, test_cases[2].0)); - } else { - panic!("expected Submitted"); - } - } - - #[test] - fn test_placeholder_deletion() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - // Define test cases: (content, is_large) - let test_cases = [ - ("a".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5), true), - (" and ".to_string(), false), - ("b".repeat(LARGE_PASTE_CHAR_THRESHOLD + 6), true), - ]; - - // Apply all pastes - let mut current_pos = 0; - let states: Vec<_> = test_cases - .iter() - .map(|(content, is_large)| { - composer.handle_paste(content.clone()); - if *is_large { - let placeholder = format!("[Pasted Content {} chars]", content.chars().count()); - current_pos += placeholder.len(); - } else { - current_pos += content.len(); - } - ( - composer.textarea.text().to_string(), - composer.pending_pastes.len(), - current_pos, - ) - }) - .collect(); - - // Delete placeholders one by one and collect states - let mut deletion_states = vec![]; - - // First deletion - composer.textarea.set_cursor(states[0].2); - composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); - deletion_states.push(( - composer.textarea.text().to_string(), - composer.pending_pastes.len(), - )); - - // Second deletion - composer.textarea.set_cursor(composer.textarea.text().len()); - composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); - deletion_states.push(( - composer.textarea.text().to_string(), - composer.pending_pastes.len(), - )); - - // Verify all states - assert_eq!( - deletion_states, - vec![ - (" and [Pasted Content 1006 chars]".to_string(), 1), - (" and ".to_string(), 0), - ] - ); - } - - /// Behavior: if multiple large pastes share the same placeholder label (same char count), - /// deleting one placeholder removes only its corresponding `pending_pastes` entry. - #[test] - fn deleting_duplicate_length_pastes_removes_only_target() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - let paste = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4); - let placeholder_base = format!("[Pasted Content {} chars]", paste.chars().count()); - let placeholder_second = format!("{placeholder_base} #2"); - - composer.handle_paste(paste.clone()); - composer.handle_paste(paste.clone()); - assert_eq!( - composer.textarea.text(), - format!("{placeholder_base}{placeholder_second}") - ); - assert_eq!(composer.pending_pastes.len(), 2); - - composer.textarea.set_cursor(composer.textarea.text().len()); - composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); - - assert_eq!(composer.textarea.text(), placeholder_base); - assert_eq!(composer.pending_pastes.len(), 1); - assert_eq!(composer.pending_pastes[0].0, placeholder_base); - assert_eq!(composer.pending_pastes[0].1, paste); - } - - /// Behavior: large-paste placeholder numbering does not get reused after deletion, so a new - /// paste of the same length gets a new unique placeholder label. - #[test] - fn large_paste_numbering_does_not_reuse_after_deletion() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - let paste = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4); - let base = format!("[Pasted Content {} chars]", paste.chars().count()); - let second = format!("{base} #2"); - let third = format!("{base} #3"); - - composer.handle_paste(paste.clone()); - composer.handle_paste(paste.clone()); - assert_eq!(composer.textarea.text(), format!("{base}{second}")); - - composer.textarea.set_cursor(base.len()); - composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), second); - assert_eq!(composer.pending_pastes.len(), 1); - assert_eq!(composer.pending_pastes[0].0, second); - - composer.textarea.set_cursor(composer.textarea.text().len()); - composer.handle_paste(paste); - - assert_eq!(composer.textarea.text(), format!("{second}{third}")); - assert_eq!(composer.pending_pastes.len(), 2); - assert_eq!(composer.pending_pastes[0].0, second); - assert_eq!(composer.pending_pastes[1].0, third); - } - - #[test] - fn test_partial_placeholder_deletion() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - // Define test cases: (cursor_position_from_end, expected_pending_count) - let test_cases = [ - 5, // Delete from middle - should clear tracking - 0, // Delete from end - should clear tracking - ]; - - let paste = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4); - let placeholder = format!("[Pasted Content {} chars]", paste.chars().count()); - - let states: Vec<_> = test_cases - .into_iter() - .map(|pos_from_end| { - composer.handle_paste(paste.clone()); - composer - .textarea - .set_cursor(placeholder.len() - pos_from_end); - composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); - let result = ( - composer.textarea.text().contains(&placeholder), - composer.pending_pastes.len(), - ); - composer.textarea.set_text_clearing_elements(""); - result - }) - .collect(); - - assert_eq!( - states, - vec![ - (false, 0), // After deleting from middle - (false, 0), // After deleting from end - ] - ); - } - - // --- Image attachment tests --- - #[test] - fn attach_image_and_submit_includes_local_image_paths() { - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - let path = PathBuf::from("/tmp/image1.png"); - composer.attach_image(path.clone()); - composer.handle_paste(" hi".into()); - let (result, _) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - match result { - InputResult::Submitted { - text, - text_elements, - } => { - assert_eq!(text, "[Image #1] hi"); - assert_eq!(text_elements.len(), 1); - assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); - assert_eq!( - text_elements[0].byte_range, - ByteRange { - start: 0, - end: "[Image #1]".len() - } - ); - } - _ => panic!("expected Submitted"), - } - let imgs = composer.take_recent_submission_images(); - assert_eq!(vec![path], imgs); - } - - #[test] - fn set_text_content_reattaches_images_without_placeholder_metadata() { - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - let placeholder = local_image_label_text(1); - let text = format!("{placeholder} restored"); - let text_elements = vec![TextElement::new((0..placeholder.len()).into(), None)]; - let path = PathBuf::from("/tmp/image1.png"); - - composer.set_text_content(text, text_elements, vec![path.clone()]); - - assert_eq!(composer.local_image_paths(), vec![path]); - } - - #[test] - fn large_paste_preserves_image_text_elements_on_submit() { - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - let large_content = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5); - composer.handle_paste(large_content.clone()); - composer.handle_paste(" ".into()); - let path = PathBuf::from("/tmp/image_with_paste.png"); - composer.attach_image(path.clone()); - - let (result, _) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - match result { - InputResult::Submitted { - text, - text_elements, - } => { - let expected = format!("{large_content} [Image #1]"); - assert_eq!(text, expected); - assert_eq!(text_elements.len(), 1); - assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); - assert_eq!( - text_elements[0].byte_range, - ByteRange { - start: large_content.len() + 1, - end: large_content.len() + 1 + "[Image #1]".len(), - } - ); - } - _ => panic!("expected Submitted"), - } - let imgs = composer.take_recent_submission_images(); - assert_eq!(vec![path], imgs); - } - - #[test] - fn large_paste_with_leading_whitespace_trims_and_shifts_elements() { - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - let large_content = format!(" {}", "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5)); - composer.handle_paste(large_content.clone()); - composer.handle_paste(" ".into()); - let path = PathBuf::from("/tmp/image_with_trim.png"); - composer.attach_image(path.clone()); - - let (result, _) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - match result { - InputResult::Submitted { - text, - text_elements, - } => { - let trimmed = large_content.trim().to_string(); - assert_eq!(text, format!("{trimmed} [Image #1]")); - assert_eq!(text_elements.len(), 1); - assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); - assert_eq!( - text_elements[0].byte_range, - ByteRange { - start: trimmed.len() + 1, - end: trimmed.len() + 1 + "[Image #1]".len(), - } - ); - } - _ => panic!("expected Submitted"), - } - let imgs = composer.take_recent_submission_images(); - assert_eq!(vec![path], imgs); - } - - #[test] - fn pasted_crlf_normalizes_newlines_for_elements() { - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - let pasted = "line1\r\nline2\r\n".to_string(); - composer.handle_paste(pasted); - composer.handle_paste(" ".into()); - let path = PathBuf::from("/tmp/image_crlf.png"); - composer.attach_image(path.clone()); - - let (result, _) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - match result { - InputResult::Submitted { - text, - text_elements, - } => { - assert_eq!(text, "line1\nline2\n [Image #1]"); - assert!(!text.contains('\r')); - assert_eq!(text_elements.len(), 1); - assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); - assert_eq!( - text_elements[0].byte_range, - ByteRange { - start: "line1\nline2\n ".len(), - end: "line1\nline2\n [Image #1]".len(), - } - ); - } - _ => panic!("expected Submitted"), - } - let imgs = composer.take_recent_submission_images(); - assert_eq!(vec![path], imgs); - } - - #[test] - fn suppressed_submission_restores_pending_paste_payload() { - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - composer.textarea.set_text_clearing_elements("/unknown "); - composer.textarea.set_cursor("/unknown ".len()); - let large_content = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5); - composer.handle_paste(large_content.clone()); - let placeholder = composer - .pending_pastes - .first() - .expect("expected pending paste") - .0 - .clone(); - - let (result, _) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert!(matches!(result, InputResult::None)); - assert_eq!(composer.pending_pastes.len(), 1); - assert_eq!(composer.textarea.text(), format!("/unknown {placeholder}")); - - composer.textarea.set_cursor(0); - composer.textarea.insert_str(" "); - let (result, _) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - match result { - InputResult::Submitted { - text, - text_elements, - } => { - assert_eq!(text, format!("/unknown {large_content}")); - assert!(text_elements.is_empty()); - } - _ => panic!("expected Submitted"), - } - assert!(composer.pending_pastes.is_empty()); - } - - #[test] - fn attach_image_without_text_submits_empty_text_and_images() { - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - let path = PathBuf::from("/tmp/image2.png"); - composer.attach_image(path.clone()); - let (result, _) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - match result { - InputResult::Submitted { - text, - text_elements, - } => { - assert_eq!(text, "[Image #1]"); - assert_eq!(text_elements.len(), 1); - assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); - assert_eq!( - text_elements[0].byte_range, - ByteRange { - start: 0, - end: "[Image #1]".len() - } - ); - } - _ => panic!("expected Submitted"), - } - let imgs = composer.take_recent_submission_images(); - assert_eq!(imgs.len(), 1); - assert_eq!(imgs[0], path); - assert!(composer.attached_images.is_empty()); - } - - #[test] - fn image_placeholder_backspace_behaves_like_text_placeholder() { - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - let path = PathBuf::from("/tmp/image3.png"); - composer.attach_image(path.clone()); - let placeholder = composer.attached_images[0].placeholder.clone(); - - // Case 1: backspace at end - composer.textarea.move_cursor_to_end_of_line(false); - composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); - assert!(!composer.textarea.text().contains(&placeholder)); - assert!(composer.attached_images.is_empty()); - - // Re-add and test backspace in middle: should break the placeholder string - // and drop the image mapping (same as text placeholder behavior). - composer.attach_image(path); - let placeholder2 = composer.attached_images[0].placeholder.clone(); - // Move cursor to roughly middle of placeholder - if let Some(start_pos) = composer.textarea.text().find(&placeholder2) { - let mid_pos = start_pos + (placeholder2.len() / 2); - composer.textarea.set_cursor(mid_pos); - composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); - assert!(!composer.textarea.text().contains(&placeholder2)); - assert!(composer.attached_images.is_empty()); - } else { - panic!("Placeholder not found in textarea"); - } - } - - #[test] - fn backspace_with_multibyte_text_before_placeholder_does_not_panic() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - // Insert an image placeholder at the start - let path = PathBuf::from("/tmp/image_multibyte.png"); - composer.attach_image(path); - // Add multibyte text after the placeholder - composer.textarea.insert_str("日本語"); - - // Cursor is at end; pressing backspace should delete the last character - // without panicking and leave the placeholder intact. - composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); - - assert_eq!(composer.attached_images.len(), 1); - assert!(composer.textarea.text().starts_with("[Image #1]")); - } - - #[test] - fn deleting_one_of_duplicate_image_placeholders_removes_matching_entry() { - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - let path1 = PathBuf::from("/tmp/image_dup1.png"); - let path2 = PathBuf::from("/tmp/image_dup2.png"); - - composer.attach_image(path1); - // separate placeholders with a space for clarity - composer.handle_paste(" ".into()); - composer.attach_image(path2.clone()); - - let placeholder1 = composer.attached_images[0].placeholder.clone(); - let placeholder2 = composer.attached_images[1].placeholder.clone(); - let text = composer.textarea.text().to_string(); - let start1 = text.find(&placeholder1).expect("first placeholder present"); - let end1 = start1 + placeholder1.len(); - composer.textarea.set_cursor(end1); - - // Backspace should delete the first placeholder and its mapping. - composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); - - let new_text = composer.textarea.text().to_string(); - assert_eq!( - 0, - new_text.matches(&placeholder2).count(), - "second placeholder was relabeled" - ); - assert_eq!( - 1, - new_text.matches("[Image #1]").count(), - "remaining placeholder relabeled to #1" - ); - assert_eq!( - vec![AttachedImage { - path: path2, - placeholder: "[Image #1]".to_string() - }], - composer.attached_images, - "one image mapping remains" - ); - } - - #[test] - fn deleting_reordered_image_one_renumbers_text_in_place() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - let path1 = PathBuf::from("/tmp/image_first.png"); - let path2 = PathBuf::from("/tmp/image_second.png"); - let placeholder1 = local_image_label_text(1); - let placeholder2 = local_image_label_text(2); - - // Placeholders can be reordered in the text buffer; deleting image #1 should renumber - // image #2 wherever it appears, not just after the cursor. - let text = format!("Test {placeholder2} test {placeholder1}"); - let start2 = text.find(&placeholder2).expect("placeholder2 present"); - let start1 = text.find(&placeholder1).expect("placeholder1 present"); - let text_elements = vec![ - TextElement::new( - ByteRange { - start: start2, - end: start2 + placeholder2.len(), - }, - Some(placeholder2), - ), - TextElement::new( - ByteRange { - start: start1, - end: start1 + placeholder1.len(), - }, - Some(placeholder1.clone()), - ), - ]; - composer.set_text_content(text, text_elements, vec![path1, path2.clone()]); - - let end1 = start1 + placeholder1.len(); - composer.textarea.set_cursor(end1); - - composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); - - assert_eq!( - composer.textarea.text(), - format!("Test {placeholder1} test ") - ); - assert_eq!( - vec![AttachedImage { - path: path2, - placeholder: placeholder1 - }], - composer.attached_images, - "attachment renumbered after deletion" - ); - } - - #[test] - fn deleting_first_text_element_renumbers_following_text_element() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - let path1 = PathBuf::from("/tmp/image_first.png"); - let path2 = PathBuf::from("/tmp/image_second.png"); - - // Insert two adjacent atomic elements. - composer.attach_image(path1); - composer.attach_image(path2.clone()); - assert_eq!(composer.textarea.text(), "[Image #1][Image #2]"); - assert_eq!(composer.attached_images.len(), 2); - - // Delete the first element using normal textarea editing (Delete at cursor start). - composer.textarea.set_cursor(0); - composer.handle_key_event(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE)); - - // Remaining image should be renumbered and the textarea element updated. - assert_eq!(composer.attached_images.len(), 1); - assert_eq!(composer.attached_images[0].path, path2); - assert_eq!(composer.attached_images[0].placeholder, "[Image #1]"); - assert_eq!(composer.textarea.text(), "[Image #1]"); - } - - #[test] - fn pasting_filepath_attaches_image() { - let tmp = tempdir().expect("create TempDir"); - let tmp_path: PathBuf = tmp.path().join("codex_tui_test_paste_image.png"); - let img: ImageBuffer, Vec> = - ImageBuffer::from_fn(3, 2, |_x, _y| Rgba([1, 2, 3, 255])); - img.save(&tmp_path).expect("failed to write temp png"); - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - let needs_redraw = composer.handle_paste(tmp_path.to_string_lossy().to_string()); - assert!(needs_redraw); - assert!(composer.textarea.text().starts_with("[Image #1] ")); - - let imgs = composer.take_recent_submission_images(); - assert_eq!(imgs, vec![tmp_path]); - } - - #[test] - fn selecting_custom_prompt_without_args_submits_content() { - let prompt_text = "Hello from saved prompt"; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - // Inject prompts as if received via event. - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: prompt_text.to_string(), - description: None, - argument_hint: None, - }]); - - type_chars_humanlike( - &mut composer, - &[ - '/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'm', 'y', '-', 'p', 'r', 'o', 'm', - 'p', 't', - ], - ); - - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(matches!( - result, - InputResult::Submitted { text, .. } if text == prompt_text - )); - assert!(composer.textarea.is_empty()); - } - - #[test] - fn custom_prompt_submission_expands_arguments() { - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "Review $USER changes on $BRANCH".to_string(), - description: None, - argument_hint: None, - }]); - - composer - .textarea - .set_text_clearing_elements("/prompts:my-prompt USER=Alice BRANCH=main"); - - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(matches!( - result, - InputResult::Submitted { text, .. } - if text == "Review Alice changes on main" - )); - assert!(composer.textarea.is_empty()); - } - - #[test] - fn custom_prompt_submission_accepts_quoted_values() { - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "Pair $USER with $BRANCH".to_string(), - description: None, - argument_hint: None, - }]); - - composer - .textarea - .set_text_clearing_elements("/prompts:my-prompt USER=\"Alice Smith\" BRANCH=dev-main"); - - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(matches!( - result, - InputResult::Submitted { text, .. } - if text == "Pair Alice Smith with dev-main" - )); - assert!(composer.textarea.is_empty()); - } - - #[test] - fn custom_prompt_submission_preserves_image_placeholder_unquoted() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "Review $IMG".to_string(), - description: None, - argument_hint: None, - }]); - - composer - .textarea - .set_text_clearing_elements("/prompts:my-prompt IMG="); - composer.textarea.set_cursor(composer.textarea.text().len()); - let path = PathBuf::from("/tmp/image_prompt.png"); - composer.attach_image(path); - - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - match result { - InputResult::Submitted { - text, - text_elements, - } => { - let placeholder = local_image_label_text(1); - assert_eq!(text, format!("Review {placeholder}")); - assert_eq!( - text_elements, - vec![TextElement::new( - ByteRange { - start: "Review ".len(), - end: "Review ".len() + placeholder.len(), - }, - Some(placeholder), - )] - ); - } - _ => panic!("expected Submitted"), - } - } - - #[test] - fn custom_prompt_submission_preserves_image_placeholder_quoted() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "Review $IMG".to_string(), - description: None, - argument_hint: None, - }]); - - composer - .textarea - .set_text_clearing_elements("/prompts:my-prompt IMG=\""); - composer.textarea.set_cursor(composer.textarea.text().len()); - let path = PathBuf::from("/tmp/image_prompt_quoted.png"); - composer.attach_image(path); - composer.handle_paste("\"".to_string()); - - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - match result { - InputResult::Submitted { - text, - text_elements, - } => { - let placeholder = local_image_label_text(1); - assert_eq!(text, format!("Review {placeholder}")); - assert_eq!( - text_elements, - vec![TextElement::new( - ByteRange { - start: "Review ".len(), - end: "Review ".len() + placeholder.len(), - }, - Some(placeholder), - )] - ); - } - _ => panic!("expected Submitted"), - } - } - - #[test] - fn custom_prompt_submission_drops_unused_image_arg() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "Review changes".to_string(), - description: None, - argument_hint: None, - }]); - - composer - .textarea - .set_text_clearing_elements("/prompts:my-prompt IMG="); - composer.textarea.set_cursor(composer.textarea.text().len()); - let path = PathBuf::from("/tmp/unused_image.png"); - composer.attach_image(path); - - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - match result { - InputResult::Submitted { - text, - text_elements, - } => { - assert_eq!(text, "Review changes"); - assert!(text_elements.is_empty()); - } - _ => panic!("expected Submitted"), - } - assert!(composer.take_recent_submission_images().is_empty()); - } - - /// Behavior: selecting a custom prompt that includes a large paste placeholder should expand - /// to the full pasted content before submission. - #[test] - fn custom_prompt_with_large_paste_expands_correctly() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - // Create a custom prompt with positional args (no named args like $USER) - composer.set_custom_prompts(vec![CustomPrompt { - name: "code-review".to_string(), - path: "/tmp/code-review.md".to_string().into(), - content: "Please review the following code:\n\n$1".to_string(), - description: None, - argument_hint: None, - }]); - - // Type the slash command - let command_text = "/prompts:code-review "; - composer.textarea.set_text_clearing_elements(command_text); - composer.textarea.set_cursor(command_text.len()); - - // Paste large content (>3000 chars) to trigger placeholder - let large_content = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 3000); - composer.handle_paste(large_content.clone()); - - // Verify placeholder was created - let placeholder = format!("[Pasted Content {} chars]", large_content.chars().count()); - assert_eq!( - composer.textarea.text(), - format!("/prompts:code-review {}", placeholder) - ); - assert_eq!(composer.pending_pastes.len(), 1); - assert_eq!(composer.pending_pastes[0].0, placeholder); - assert_eq!(composer.pending_pastes[0].1, large_content); - - // Submit by pressing Enter - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - // Verify the custom prompt was expanded with the large content as positional arg - match result { - InputResult::Submitted { text, .. } => { - // The prompt should be expanded, with the large content replacing $1 - assert_eq!( - text, - format!("Please review the following code:\n\n{}", large_content), - "Expected prompt expansion with large content as $1" - ); - } - _ => panic!("expected Submitted, got: {result:?}"), - } - assert!(composer.textarea.is_empty()); - assert!(composer.pending_pastes.is_empty()); - } - - #[test] - fn custom_prompt_with_large_paste_and_image_preserves_elements() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "Review $IMG\n\n$CODE".to_string(), - description: None, - argument_hint: None, - }]); - - composer - .textarea - .set_text_clearing_elements("/prompts:my-prompt IMG="); - composer.textarea.set_cursor(composer.textarea.text().len()); - let path = PathBuf::from("/tmp/image_prompt_combo.png"); - composer.attach_image(path); - composer.handle_paste(" CODE=".to_string()); - let large_content = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5); - composer.handle_paste(large_content.clone()); - - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - match result { - InputResult::Submitted { - text, - text_elements, - } => { - let placeholder = local_image_label_text(1); - assert_eq!(text, format!("Review {placeholder}\n\n{large_content}")); - assert_eq!( - text_elements, - vec![TextElement::new( - ByteRange { - start: "Review ".len(), - end: "Review ".len() + placeholder.len(), - }, - Some(placeholder), - )] - ); - } - _ => panic!("expected Submitted"), - } - } - - #[test] - fn slash_path_input_submits_without_command_error() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, mut rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - composer - .textarea - .set_text_clearing_elements("/Users/example/project/src/main.rs"); - - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - if let InputResult::Submitted { text, .. } = result { - assert_eq!(text, "/Users/example/project/src/main.rs"); - } else { - panic!("expected Submitted"); - } - assert!(composer.textarea.is_empty()); - match rx.try_recv() { - Ok(event) => panic!("unexpected event: {event:?}"), - Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {} - Err(err) => panic!("unexpected channel state: {err:?}"), - } - } - - #[test] - fn slash_with_leading_space_submits_as_text() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, mut rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - composer - .textarea - .set_text_clearing_elements(" /this-looks-like-a-command"); - - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - if let InputResult::Submitted { text, .. } = result { - assert_eq!(text, "/this-looks-like-a-command"); - } else { - panic!("expected Submitted"); - } - assert!(composer.textarea.is_empty()); - match rx.try_recv() { - Ok(event) => panic!("unexpected event: {event:?}"), - Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {} - Err(err) => panic!("unexpected channel state: {err:?}"), - } - } - - #[test] - fn custom_prompt_invalid_args_reports_error() { - let (tx, mut rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "Review $USER changes".to_string(), - description: None, - argument_hint: None, - }]); - - composer - .textarea - .set_text_clearing_elements("/prompts:my-prompt USER=Alice stray"); - - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - assert_eq!(InputResult::None, result); - assert_eq!( - "/prompts:my-prompt USER=Alice stray", - composer.textarea.text() - ); - - let mut found_error = false; - while let Ok(event) = rx.try_recv() { - if let AppEvent::InsertHistoryCell(cell) = event { - let message = cell - .display_lines(80) - .into_iter() - .map(|line| line.to_string()) - .collect::>() - .join("\n"); - assert!(message.contains("expected key=value")); - found_error = true; - break; - } - } - assert!(found_error, "expected error history cell to be sent"); - } - - #[test] - fn custom_prompt_missing_required_args_reports_error() { - let (tx, mut rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "Review $USER changes on $BRANCH".to_string(), - description: None, - argument_hint: None, - }]); - - // Provide only one of the required args - composer - .textarea - .set_text_clearing_elements("/prompts:my-prompt USER=Alice"); - - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - assert_eq!(InputResult::None, result); - assert_eq!("/prompts:my-prompt USER=Alice", composer.textarea.text()); - - let mut found_error = false; - while let Ok(event) = rx.try_recv() { - if let AppEvent::InsertHistoryCell(cell) = event { - let message = cell - .display_lines(80) - .into_iter() - .map(|line| line.to_string()) - .collect::>() - .join("\n"); - assert!(message.to_lowercase().contains("missing required args")); - assert!(message.contains("BRANCH")); - found_error = true; - break; - } - } - assert!( - found_error, - "expected missing args error history cell to be sent" - ); - } - - #[test] - fn selecting_custom_prompt_with_args_expands_placeholders() { - // Support $1..$9 and $ARGUMENTS in prompt content. - let prompt_text = "Header: $1\nArgs: $ARGUMENTS\nNinth: $9\n"; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: prompt_text.to_string(), - description: None, - argument_hint: None, - }]); - - // Type the slash command with two args and hit Enter to submit. - type_chars_humanlike( - &mut composer, - &[ - '/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'm', 'y', '-', 'p', 'r', 'o', 'm', - 'p', 't', ' ', 'f', 'o', 'o', ' ', 'b', 'a', 'r', - ], - ); - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - let expected = "Header: foo\nArgs: foo bar\nNinth: \n".to_string(); - assert!(matches!( - result, - InputResult::Submitted { text, .. } if text == expected - )); - } - - #[test] - fn popup_prompt_submission_prunes_unused_image_attachments() { - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "Hello".to_string(), - description: None, - argument_hint: None, - }]); - - composer.attach_image(PathBuf::from("/tmp/unused.png")); - composer.textarea.set_cursor(0); - composer.handle_paste(format!("/{PROMPTS_CMD_PREFIX}:my-prompt ")); - - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(matches!( - result, - InputResult::Submitted { text, .. } if text == "Hello" - )); - assert!( - composer - .take_recent_submission_images_with_placeholders() - .is_empty() - ); - } - - #[test] - fn numeric_prompt_auto_submit_prunes_unused_image_attachments() { - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "Hello $1".to_string(), - description: None, - argument_hint: None, - }]); - - type_chars_humanlike( - &mut composer, - &[ - '/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'm', 'y', '-', 'p', 'r', 'o', 'm', - 'p', 't', ' ', 'f', 'o', 'o', ' ', - ], - ); - composer.attach_image(PathBuf::from("/tmp/unused.png")); - - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(matches!( - result, - InputResult::Submitted { text, .. } if text == "Hello foo" - )); - assert!( - composer - .take_recent_submission_images_with_placeholders() - .is_empty() - ); - } - - #[test] - fn numeric_prompt_auto_submit_expands_pending_pastes() { - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "Echo: $1".to_string(), - description: None, - argument_hint: None, - }]); - - composer - .textarea - .set_text_clearing_elements("/prompts:my-prompt "); - composer.textarea.set_cursor(composer.textarea.text().len()); - let large_content = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5); - composer.handle_paste(large_content.clone()); - - assert_eq!(composer.pending_pastes.len(), 1); - - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - let expected = format!("Echo: {large_content}"); - assert!(matches!( - result, - InputResult::Submitted { text, .. } if text == expected - )); - assert!(composer.pending_pastes.is_empty()); - } - - #[test] - fn queued_prompt_submission_prunes_unused_image_attachments() { - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(false); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "Hello $1".to_string(), - description: None, - argument_hint: None, - }]); - - composer - .textarea - .set_text_clearing_elements("/prompts:my-prompt foo "); - composer.textarea.set_cursor(composer.textarea.text().len()); - composer.attach_image(PathBuf::from("/tmp/unused.png")); - - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(matches!( - result, - InputResult::Queued { text, .. } if text == "Hello foo" - )); - assert!( - composer - .take_recent_submission_images_with_placeholders() - .is_empty() - ); - } - - #[test] - fn selecting_custom_prompt_with_positional_args_submits_numeric_expansion() { - let prompt_text = "Header: $1\nArgs: $ARGUMENTS\n"; - - let prompt = CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: prompt_text.to_string(), - description: None, - argument_hint: None, - }; - - let action = prompt_selection_action( - &prompt, - "/prompts:my-prompt foo bar", - PromptSelectionMode::Submit, - &[], - ); - match action { - PromptSelectionAction::Submit { - text, - text_elements, - } => { - assert_eq!(text, "Header: foo\nArgs: foo bar\n"); - assert!(text_elements.is_empty()); - } - _ => panic!("expected Submit action"), - } - } - - #[test] - fn numeric_prompt_positional_args_does_not_error() { - // Ensure that a prompt with only numeric placeholders does not trigger - // key=value parsing errors when given positional arguments. - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "elegant".to_string(), - path: "/tmp/elegant.md".to_string().into(), - content: "Echo: $ARGUMENTS".to_string(), - description: None, - argument_hint: None, - }]); - - // Type positional args; should submit with numeric expansion, no errors. - composer - .textarea - .set_text_clearing_elements("/prompts:elegant hi"); - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(matches!( - result, - InputResult::Submitted { text, .. } if text == "Echo: hi" - )); - assert!(composer.textarea.is_empty()); - } - - #[test] - fn selecting_custom_prompt_with_no_args_inserts_template() { - let prompt_text = "X:$1 Y:$2 All:[$ARGUMENTS]"; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "p".to_string(), - path: "/tmp/p.md".to_string().into(), - content: prompt_text.to_string(), - description: None, - argument_hint: None, - }]); - - type_chars_humanlike( - &mut composer, - &['/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'p'], - ); - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - // With no args typed, selecting the prompt inserts the command template - // and does not submit immediately. - assert_eq!(InputResult::None, result); - assert_eq!("/prompts:p ", composer.textarea.text()); - } - - #[test] - fn selecting_custom_prompt_preserves_literal_dollar_dollar() { - // '$$' should remain untouched. - let prompt_text = "Cost: $$ and first: $1"; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "price".to_string(), - path: "/tmp/price.md".to_string().into(), - content: prompt_text.to_string(), - description: None, - argument_hint: None, - }]); - - type_chars_humanlike( - &mut composer, - &[ - '/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'p', 'r', 'i', 'c', 'e', ' ', 'x', - ], - ); - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(matches!( - result, - InputResult::Submitted { text, .. } - if text == "Cost: $$ and first: x" - )); - } - - #[test] - fn selecting_custom_prompt_reuses_cached_arguments_join() { - let prompt_text = "First: $ARGUMENTS\nSecond: $ARGUMENTS"; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "repeat".to_string(), - path: "/tmp/repeat.md".to_string().into(), - content: prompt_text.to_string(), - description: None, - argument_hint: None, - }]); - - type_chars_humanlike( - &mut composer, - &[ - '/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'r', 'e', 'p', 'e', 'a', 't', ' ', - 'o', 'n', 'e', ' ', 't', 'w', 'o', - ], - ); - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - let expected = "First: one two\nSecond: one two".to_string(); - assert!(matches!( - result, - InputResult::Submitted { text, .. } if text == expected - )); - } - - /// Behavior: fast "paste-like" ASCII input should buffer and then flush as a single paste. If - /// the payload is small, it should insert directly (no placeholder). - #[test] - fn burst_paste_fast_small_buffers_and_flushes_on_stop() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - let count = 32; - let mut now = Instant::now(); - let step = Duration::from_millis(1); - for _ in 0..count { - let _ = composer.handle_input_basic_with_time( - KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE), - now, - ); - assert!( - composer.is_in_paste_burst(), - "expected active paste burst during fast typing" - ); - assert!( - composer.textarea.text().is_empty(), - "text should not appear during burst" - ); - now += step; - } - - assert!( - composer.textarea.text().is_empty(), - "text should remain empty until flush" - ); - let flush_time = now + PasteBurst::recommended_active_flush_delay() + step; - let flushed = composer.handle_paste_burst_flush(flush_time); - assert!(flushed, "expected buffered text to flush after stop"); - assert_eq!(composer.textarea.text(), "a".repeat(count)); - assert!( - composer.pending_pastes.is_empty(), - "no placeholder for small burst" - ); - } - - /// Behavior: fast "paste-like" ASCII input should buffer and then flush as a single paste. If - /// the payload is large, it should insert a placeholder and defer the full text until submit. - #[test] - fn burst_paste_fast_large_inserts_placeholder_on_flush() { - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - let count = LARGE_PASTE_CHAR_THRESHOLD + 1; // > threshold to trigger placeholder - let mut now = Instant::now(); - let step = Duration::from_millis(1); - for _ in 0..count { - let _ = composer.handle_input_basic_with_time( - KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), - now, - ); - now += step; - } - - // Nothing should appear until we stop and flush - assert!(composer.textarea.text().is_empty()); - let flush_time = now + PasteBurst::recommended_active_flush_delay() + step; - let flushed = composer.handle_paste_burst_flush(flush_time); - assert!(flushed, "expected flush after stopping fast input"); - - let expected_placeholder = format!("[Pasted Content {count} chars]"); - assert_eq!(composer.textarea.text(), expected_placeholder); - assert_eq!(composer.pending_pastes.len(), 1); - assert_eq!(composer.pending_pastes[0].0, expected_placeholder); - assert_eq!(composer.pending_pastes[0].1.len(), count); - assert!(composer.pending_pastes[0].1.chars().all(|c| c == 'x')); - } - - /// Behavior: human-like typing (with delays between chars) should not be classified as a paste - /// burst. Characters should appear immediately and should not trigger a paste placeholder. - #[test] - fn humanlike_typing_1000_chars_appears_live_no_placeholder() { - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - let count = LARGE_PASTE_CHAR_THRESHOLD; // 1000 in current config - let chars: Vec = vec!['z'; count]; - type_chars_humanlike(&mut composer, &chars); - - assert_eq!(composer.textarea.text(), "z".repeat(count)); - assert!(composer.pending_pastes.is_empty()); - } - - #[test] - fn slash_popup_not_activated_for_slash_space_text_history_like_input() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - use tokio::sync::mpsc::unbounded_channel; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - // Simulate history-like content: "/ test" - composer.set_text_content("/ test".to_string(), Vec::new(), Vec::new()); - - // After set_text_content -> sync_popups is called; popup should NOT be Command. - assert!( - matches!(composer.active_popup, ActivePopup::None), - "expected no slash popup for '/ test'" - ); - - // Up should be handled by history navigation path, not slash popup handler. - let (result, _redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); - assert_eq!(result, InputResult::None); - } - - #[test] - fn slash_popup_activated_for_bare_slash_and_valid_prefixes() { - // use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; - use tokio::sync::mpsc::unbounded_channel; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - // Case 1: bare "/" - composer.set_text_content("/".to_string(), Vec::new(), Vec::new()); - assert!( - matches!(composer.active_popup, ActivePopup::Command(_)), - "bare '/' should activate slash popup" - ); - - // Case 2: valid prefix "/re" (matches /review, /resume, etc.) - composer.set_text_content("/re".to_string(), Vec::new(), Vec::new()); - assert!( - matches!(composer.active_popup, ActivePopup::Command(_)), - "'/re' should activate slash popup via prefix match" - ); - - // Case 3: fuzzy match "/ac" (subsequence of /compact and /feedback) - composer.set_text_content("/ac".to_string(), Vec::new(), Vec::new()); - assert!( - matches!(composer.active_popup, ActivePopup::Command(_)), - "'/ac' should activate slash popup via fuzzy match" - ); - - // Case 4: invalid prefix "/zzz" – still allowed to open popup if it - // matches no built-in command; our current logic will not open popup. - // Verify that explicitly. - composer.set_text_content("/zzz".to_string(), Vec::new(), Vec::new()); - assert!( - matches!(composer.active_popup, ActivePopup::None), - "'/zzz' should not activate slash popup because it is not a prefix of any built-in command" - ); - } - - #[test] - fn input_disabled_ignores_keypresses_and_hides_cursor() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - composer.set_text_content("hello".to_string(), Vec::new(), Vec::new()); - composer.set_input_enabled(false, Some("Input disabled for test.".to_string())); - - let (result, needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); - - assert_eq!(result, InputResult::None); - assert!(!needs_redraw); - assert_eq!(composer.current_text(), "hello"); - - let area = Rect { - x: 0, - y: 0, - width: 40, - height: 5, - }; - assert_eq!(composer.cursor_pos(area), None); - } -} diff --git a/codex-rs/tui2/src/bottom_pane/chat_composer_history.rs b/codex-rs/tui2/src/bottom_pane/chat_composer_history.rs deleted file mode 100644 index 991283a5663..00000000000 --- a/codex-rs/tui2/src/bottom_pane/chat_composer_history.rs +++ /dev/null @@ -1,300 +0,0 @@ -use std::collections::HashMap; - -use crate::app_event::AppEvent; -use crate::app_event_sender::AppEventSender; -use codex_core::protocol::Op; - -/// State machine that manages shell-style history navigation (Up/Down) inside -/// the chat composer. This struct is intentionally decoupled from the -/// rendering widget so the logic remains isolated and easier to test. -pub(crate) struct ChatComposerHistory { - /// Identifier of the history log as reported by `SessionConfiguredEvent`. - history_log_id: Option, - /// Number of entries already present in the persistent cross-session - /// history file when the session started. - history_entry_count: usize, - - /// Messages submitted by the user *during this UI session* (newest at END). - local_history: Vec, - - /// Cache of persistent history entries fetched on-demand. - fetched_history: HashMap, - - /// Current cursor within the combined (persistent + local) history. `None` - /// indicates the user is *not* currently browsing history. - history_cursor: Option, - - /// The text that was last inserted into the composer as a result of - /// history navigation. Used to decide if further Up/Down presses should be - /// treated as navigation versus normal cursor movement. - last_history_text: Option, -} - -impl ChatComposerHistory { - pub fn new() -> Self { - Self { - history_log_id: None, - history_entry_count: 0, - local_history: Vec::new(), - fetched_history: HashMap::new(), - history_cursor: None, - last_history_text: None, - } - } - - /// Update metadata when a new session is configured. - pub fn set_metadata(&mut self, log_id: u64, entry_count: usize) { - self.history_log_id = Some(log_id); - self.history_entry_count = entry_count; - self.fetched_history.clear(); - self.local_history.clear(); - self.history_cursor = None; - self.last_history_text = None; - } - - /// Record a message submitted by the user in the current session so it can - /// be recalled later. - pub fn record_local_submission(&mut self, text: &str) { - if text.is_empty() { - return; - } - - self.history_cursor = None; - self.last_history_text = None; - - // Avoid inserting a duplicate if identical to the previous entry. - if self.local_history.last().is_some_and(|prev| prev == text) { - return; - } - - self.local_history.push(text.to_string()); - } - - /// Reset navigation tracking so the next Up key resumes from the latest entry. - pub fn reset_navigation(&mut self) { - self.history_cursor = None; - self.last_history_text = None; - } - - /// Should Up/Down key presses be interpreted as history navigation given - /// the current content and cursor position of `textarea`? - pub fn should_handle_navigation(&self, text: &str, cursor: usize) -> bool { - if self.history_entry_count == 0 && self.local_history.is_empty() { - return false; - } - - if text.is_empty() { - return true; - } - - // Textarea is not empty – only navigate when cursor is at start and - // text matches last recalled history entry so regular editing is not - // hijacked. - if cursor != 0 { - return false; - } - - matches!(&self.last_history_text, Some(prev) if prev == text) - } - - /// Handle . Returns true when the key was consumed and the caller - /// should request a redraw. - pub fn navigate_up(&mut self, app_event_tx: &AppEventSender) -> Option { - let total_entries = self.history_entry_count + self.local_history.len(); - if total_entries == 0 { - return None; - } - - let next_idx = match self.history_cursor { - None => (total_entries as isize) - 1, - Some(0) => return None, // already at oldest - Some(idx) => idx - 1, - }; - - self.history_cursor = Some(next_idx); - self.populate_history_at_index(next_idx as usize, app_event_tx) - } - - /// Handle . - pub fn navigate_down(&mut self, app_event_tx: &AppEventSender) -> Option { - let total_entries = self.history_entry_count + self.local_history.len(); - if total_entries == 0 { - return None; - } - - let next_idx_opt = match self.history_cursor { - None => return None, // not browsing - Some(idx) if (idx as usize) + 1 >= total_entries => None, - Some(idx) => Some(idx + 1), - }; - - match next_idx_opt { - Some(idx) => { - self.history_cursor = Some(idx); - self.populate_history_at_index(idx as usize, app_event_tx) - } - None => { - // Past newest – clear and exit browsing mode. - self.history_cursor = None; - self.last_history_text = None; - Some(String::new()) - } - } - } - - /// Integrate a GetHistoryEntryResponse event. - pub fn on_entry_response( - &mut self, - log_id: u64, - offset: usize, - entry: Option, - ) -> Option { - if self.history_log_id != Some(log_id) { - return None; - } - let text = entry?; - self.fetched_history.insert(offset, text.clone()); - - if self.history_cursor == Some(offset as isize) { - self.last_history_text = Some(text.clone()); - return Some(text); - } - None - } - - // --------------------------------------------------------------------- - // Internal helpers - // --------------------------------------------------------------------- - - fn populate_history_at_index( - &mut self, - global_idx: usize, - app_event_tx: &AppEventSender, - ) -> Option { - if global_idx >= self.history_entry_count { - // Local entry. - if let Some(text) = self - .local_history - .get(global_idx - self.history_entry_count) - { - self.last_history_text = Some(text.clone()); - return Some(text.clone()); - } - } else if let Some(text) = self.fetched_history.get(&global_idx) { - self.last_history_text = Some(text.clone()); - return Some(text.clone()); - } else if let Some(log_id) = self.history_log_id { - let op = Op::GetHistoryEntryRequest { - offset: global_idx, - log_id, - }; - app_event_tx.send(AppEvent::CodexOp(op)); - } - None - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::app_event::AppEvent; - use codex_core::protocol::Op; - use tokio::sync::mpsc::unbounded_channel; - - #[test] - fn duplicate_submissions_are_not_recorded() { - let mut history = ChatComposerHistory::new(); - - // Empty submissions are ignored. - history.record_local_submission(""); - assert_eq!(history.local_history.len(), 0); - - // First entry is recorded. - history.record_local_submission("hello"); - assert_eq!(history.local_history.len(), 1); - assert_eq!(history.local_history.last().unwrap(), "hello"); - - // Identical consecutive entry is skipped. - history.record_local_submission("hello"); - assert_eq!(history.local_history.len(), 1); - - // Different entry is recorded. - history.record_local_submission("world"); - assert_eq!(history.local_history.len(), 2); - assert_eq!(history.local_history.last().unwrap(), "world"); - } - - #[test] - fn navigation_with_async_fetch() { - let (tx, mut rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx); - - let mut history = ChatComposerHistory::new(); - // Pretend there are 3 persistent entries. - history.set_metadata(1, 3); - - // First Up should request offset 2 (latest) and await async data. - assert!(history.should_handle_navigation("", 0)); - assert!(history.navigate_up(&tx).is_none()); // don't replace the text yet - - // Verify that an AppEvent::CodexOp with the correct GetHistoryEntryRequest was sent. - let event = rx.try_recv().expect("expected AppEvent to be sent"); - let AppEvent::CodexOp(history_request1) = event else { - panic!("unexpected event variant"); - }; - assert_eq!( - Op::GetHistoryEntryRequest { - log_id: 1, - offset: 2 - }, - history_request1 - ); - - // Inject the async response. - assert_eq!( - Some("latest".into()), - history.on_entry_response(1, 2, Some("latest".into())) - ); - - // Next Up should move to offset 1. - assert!(history.navigate_up(&tx).is_none()); // don't replace the text yet - - // Verify second CodexOp event for offset 1. - let event2 = rx.try_recv().expect("expected second event"); - let AppEvent::CodexOp(history_request_2) = event2 else { - panic!("unexpected event variant"); - }; - assert_eq!( - Op::GetHistoryEntryRequest { - log_id: 1, - offset: 1 - }, - history_request_2 - ); - - assert_eq!( - Some("older".into()), - history.on_entry_response(1, 1, Some("older".into())) - ); - } - - #[test] - fn reset_navigation_resets_cursor() { - let (tx, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx); - - let mut history = ChatComposerHistory::new(); - history.set_metadata(1, 3); - history.fetched_history.insert(1, "command2".into()); - history.fetched_history.insert(2, "command3".into()); - - assert_eq!(Some("command3".into()), history.navigate_up(&tx)); - assert_eq!(Some("command2".into()), history.navigate_up(&tx)); - - history.reset_navigation(); - assert!(history.history_cursor.is_none()); - assert!(history.last_history_text.is_none()); - - assert_eq!(Some("command3".into()), history.navigate_up(&tx)); - } -} diff --git a/codex-rs/tui2/src/bottom_pane/command_popup.rs b/codex-rs/tui2/src/bottom_pane/command_popup.rs deleted file mode 100644 index 47bae3a34ff..00000000000 --- a/codex-rs/tui2/src/bottom_pane/command_popup.rs +++ /dev/null @@ -1,459 +0,0 @@ -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -use ratatui::widgets::WidgetRef; - -use super::popup_consts::MAX_POPUP_ROWS; -use super::scroll_state::ScrollState; -use super::selection_popup_common::GenericDisplayRow; -use super::selection_popup_common::render_rows; -use crate::render::Insets; -use crate::render::RectExt; -use crate::slash_command::SlashCommand; -use crate::slash_command::built_in_slash_commands; -use codex_common::fuzzy_match::fuzzy_match; -use codex_protocol::custom_prompts::CustomPrompt; -use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; -use std::collections::HashSet; - -fn windows_degraded_sandbox_active() -> bool { - cfg!(target_os = "windows") - && codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED - && codex_core::get_platform_sandbox().is_some() - && !codex_core::is_windows_elevated_sandbox_enabled() -} - -/// A selectable item in the popup: either a built-in command or a user prompt. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum CommandItem { - Builtin(SlashCommand), - // Index into `prompts` - UserPrompt(usize), -} - -pub(crate) struct CommandPopup { - command_filter: String, - builtins: Vec<(&'static str, SlashCommand)>, - prompts: Vec, - state: ScrollState, -} - -#[derive(Clone, Copy, Debug, Default)] -pub(crate) struct CommandPopupFlags { - pub(crate) skills_enabled: bool, - pub(crate) collaboration_modes_enabled: bool, -} - -impl CommandPopup { - pub(crate) fn new(mut prompts: Vec, flags: CommandPopupFlags) -> Self { - let allow_elevate_sandbox = windows_degraded_sandbox_active(); - let builtins: Vec<(&'static str, SlashCommand)> = built_in_slash_commands() - .into_iter() - .filter(|(_, cmd)| flags.skills_enabled || *cmd != SlashCommand::Skills) - .filter(|(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox) - .filter(|(_, cmd)| flags.collaboration_modes_enabled || *cmd != SlashCommand::Collab) - .collect(); - // Exclude prompts that collide with builtin command names and sort by name. - let exclude: HashSet = builtins.iter().map(|(n, _)| (*n).to_string()).collect(); - prompts.retain(|p| !exclude.contains(&p.name)); - prompts.sort_by(|a, b| a.name.cmp(&b.name)); - Self { - command_filter: String::new(), - builtins, - prompts, - state: ScrollState::new(), - } - } - - pub(crate) fn set_prompts(&mut self, mut prompts: Vec) { - let exclude: HashSet = self - .builtins - .iter() - .map(|(n, _)| (*n).to_string()) - .collect(); - prompts.retain(|p| !exclude.contains(&p.name)); - prompts.sort_by(|a, b| a.name.cmp(&b.name)); - self.prompts = prompts; - } - - pub(crate) fn prompt(&self, idx: usize) -> Option<&CustomPrompt> { - self.prompts.get(idx) - } - - /// Update the filter string based on the current composer text. The text - /// passed in is expected to start with a leading '/'. Everything after the - /// *first* '/" on the *first* line becomes the active filter that is used - /// to narrow down the list of available commands. - pub(crate) fn on_composer_text_change(&mut self, text: String) { - let first_line = text.lines().next().unwrap_or(""); - - if let Some(stripped) = first_line.strip_prefix('/') { - // Extract the *first* token (sequence of non-whitespace - // characters) after the slash so that `/clear something` still - // shows the help for `/clear`. - let token = stripped.trim_start(); - let cmd_token = token.split_whitespace().next().unwrap_or(""); - - // Update the filter keeping the original case (commands are all - // lower-case for now but this may change in the future). - self.command_filter = cmd_token.to_string(); - } else { - // The composer no longer starts with '/'. Reset the filter so the - // popup shows the *full* command list if it is still displayed - // for some reason. - self.command_filter.clear(); - } - - // Reset or clamp selected index based on new filtered list. - let matches_len = self.filtered_items().len(); - self.state.clamp_selection(matches_len); - self.state - .ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len)); - } - - /// Determine the preferred height of the popup for a given width. - /// Accounts for wrapped descriptions so that long tooltips don't overflow. - pub(crate) fn calculate_required_height(&self, width: u16) -> u16 { - use super::selection_popup_common::measure_rows_height; - let rows = self.rows_from_matches(self.filtered()); - - measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width) - } - - /// Compute fuzzy-filtered matches over built-in commands and user prompts, - /// paired with optional highlight indices and score. Preserves the original - /// presentation order for built-ins and prompts. - fn filtered(&self) -> Vec<(CommandItem, Option>, i32)> { - let filter = self.command_filter.trim(); - let mut out: Vec<(CommandItem, Option>, i32)> = Vec::new(); - if filter.is_empty() { - // Built-ins first, in presentation order. - for (_, cmd) in self.builtins.iter() { - out.push((CommandItem::Builtin(*cmd), None, 0)); - } - // Then prompts, already sorted by name. - for idx in 0..self.prompts.len() { - out.push((CommandItem::UserPrompt(idx), None, 0)); - } - return out; - } - - for (_, cmd) in self.builtins.iter() { - if let Some((indices, score)) = fuzzy_match(cmd.command(), filter) { - out.push((CommandItem::Builtin(*cmd), Some(indices), score)); - } - } - // Support both search styles: - // - Typing "name" should surface "/prompts:name" results. - // - Typing "prompts:name" should also work. - for (idx, p) in self.prompts.iter().enumerate() { - let display = format!("{PROMPTS_CMD_PREFIX}:{}", p.name); - if let Some((indices, score)) = fuzzy_match(&display, filter) { - out.push((CommandItem::UserPrompt(idx), Some(indices), score)); - } - } - out - } - - fn filtered_items(&self) -> Vec { - self.filtered().into_iter().map(|(c, _, _)| c).collect() - } - - fn rows_from_matches( - &self, - matches: Vec<(CommandItem, Option>, i32)>, - ) -> Vec { - matches - .into_iter() - .map(|(item, indices, _)| { - let (name, description) = match item { - CommandItem::Builtin(cmd) => { - (format!("/{}", cmd.command()), cmd.description().to_string()) - } - CommandItem::UserPrompt(i) => { - let prompt = &self.prompts[i]; - let description = prompt - .description - .clone() - .unwrap_or_else(|| "send saved prompt".to_string()); - ( - format!("/{PROMPTS_CMD_PREFIX}:{}", prompt.name), - description, - ) - } - }; - GenericDisplayRow { - name, - match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()), - display_shortcut: None, - description: Some(description), - wrap_indent: None, - } - }) - .collect() - } - - /// Move the selection cursor one step up. - pub(crate) fn move_up(&mut self) { - let len = self.filtered_items().len(); - self.state.move_up_wrap(len); - self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); - } - - /// Move the selection cursor one step down. - pub(crate) fn move_down(&mut self) { - let matches_len = self.filtered_items().len(); - self.state.move_down_wrap(matches_len); - self.state - .ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len)); - } - - /// Return currently selected command, if any. - pub(crate) fn selected_item(&self) -> Option { - let matches = self.filtered_items(); - self.state - .selected_idx - .and_then(|idx| matches.get(idx).copied()) - } -} - -impl WidgetRef for CommandPopup { - fn render_ref(&self, area: Rect, buf: &mut Buffer) { - let rows = self.rows_from_matches(self.filtered()); - render_rows( - area.inset(Insets::tlbr(0, 2, 0, 0)), - buf, - &rows, - &self.state, - MAX_POPUP_ROWS, - "no matches", - ); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - - #[test] - fn filter_includes_init_when_typing_prefix() { - let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); - // Simulate the composer line starting with '/in' so the popup filters - // matching commands by prefix. - popup.on_composer_text_change("/in".to_string()); - - // Access the filtered list via the selected command and ensure that - // one of the matches is the new "init" command. - let matches = popup.filtered_items(); - let has_init = matches.iter().any(|item| match item { - CommandItem::Builtin(cmd) => cmd.command() == "init", - CommandItem::UserPrompt(_) => false, - }); - assert!( - has_init, - "expected '/init' to appear among filtered commands" - ); - } - - #[test] - fn selecting_init_by_exact_match() { - let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); - popup.on_composer_text_change("/init".to_string()); - - // When an exact match exists, the selected command should be that - // command by default. - let selected = popup.selected_item(); - match selected { - Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "init"), - Some(CommandItem::UserPrompt(_)) => panic!("unexpected prompt selected for '/init'"), - None => panic!("expected a selected command for exact match"), - } - } - - #[test] - fn model_is_first_suggestion_for_mo() { - let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); - popup.on_composer_text_change("/mo".to_string()); - let matches = popup.filtered_items(); - match matches.first() { - Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "model"), - Some(CommandItem::UserPrompt(_)) => { - panic!("unexpected prompt ranked before '/model' for '/mo'") - } - None => panic!("expected at least one match for '/mo'"), - } - } - - #[test] - fn filtered_commands_keep_presentation_order() { - let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); - popup.on_composer_text_change("/m".to_string()); - - let cmds: Vec<&str> = popup - .filtered_items() - .into_iter() - .filter_map(|item| match item { - CommandItem::Builtin(cmd) => Some(cmd.command()), - CommandItem::UserPrompt(_) => None, - }) - .collect(); - assert_eq!( - cmds, - vec![ - "model", - "experimental", - "resume", - "compact", - "mention", - "mcp" - ] - ); - } - - #[test] - fn prompt_discovery_lists_custom_prompts() { - let prompts = vec![ - CustomPrompt { - name: "foo".to_string(), - path: "/tmp/foo.md".to_string().into(), - content: "hello from foo".to_string(), - description: None, - argument_hint: None, - }, - CustomPrompt { - name: "bar".to_string(), - path: "/tmp/bar.md".to_string().into(), - content: "hello from bar".to_string(), - description: None, - argument_hint: None, - }, - ]; - let popup = CommandPopup::new(prompts, CommandPopupFlags::default()); - let items = popup.filtered_items(); - let mut prompt_names: Vec = items - .into_iter() - .filter_map(|it| match it { - CommandItem::UserPrompt(i) => popup.prompt(i).map(|p| p.name.clone()), - _ => None, - }) - .collect(); - prompt_names.sort(); - assert_eq!(prompt_names, vec!["bar".to_string(), "foo".to_string()]); - } - - #[test] - fn prompt_name_collision_with_builtin_is_ignored() { - // Create a prompt named like a builtin (e.g. "init"). - let popup = CommandPopup::new( - vec![CustomPrompt { - name: "init".to_string(), - path: "/tmp/init.md".to_string().into(), - content: "should be ignored".to_string(), - description: None, - argument_hint: None, - }], - CommandPopupFlags::default(), - ); - let items = popup.filtered_items(); - let has_collision_prompt = items.into_iter().any(|it| match it { - CommandItem::UserPrompt(i) => popup.prompt(i).is_some_and(|p| p.name == "init"), - _ => false, - }); - assert!( - !has_collision_prompt, - "prompt with builtin name should be ignored" - ); - } - - #[test] - fn prompt_description_uses_frontmatter_metadata() { - let popup = CommandPopup::new( - vec![CustomPrompt { - name: "draftpr".to_string(), - path: "/tmp/draftpr.md".to_string().into(), - content: "body".to_string(), - description: Some("Create feature branch, commit and open draft PR.".to_string()), - argument_hint: None, - }], - CommandPopupFlags::default(), - ); - let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None, 0)]); - let description = rows.first().and_then(|row| row.description.as_deref()); - assert_eq!( - description, - Some("Create feature branch, commit and open draft PR.") - ); - } - - #[test] - fn prompt_description_falls_back_when_missing() { - let popup = CommandPopup::new( - vec![CustomPrompt { - name: "foo".to_string(), - path: "/tmp/foo.md".to_string().into(), - content: "body".to_string(), - description: None, - argument_hint: None, - }], - CommandPopupFlags::default(), - ); - let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None, 0)]); - let description = rows.first().and_then(|row| row.description.as_deref()); - assert_eq!(description, Some("send saved prompt")); - } - - #[test] - fn fuzzy_filter_matches_subsequence_for_ac() { - let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); - popup.on_composer_text_change("/ac".to_string()); - - let cmds: Vec<&str> = popup - .filtered_items() - .into_iter() - .filter_map(|item| match item { - CommandItem::Builtin(cmd) => Some(cmd.command()), - CommandItem::UserPrompt(_) => None, - }) - .collect(); - assert!( - cmds.contains(&"compact") && cmds.contains(&"feedback"), - "expected fuzzy search for '/ac' to include compact and feedback, got {cmds:?}" - ); - } - - #[test] - fn collab_command_hidden_when_collaboration_modes_disabled() { - let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); - popup.on_composer_text_change("/coll".to_string()); - - let cmds: Vec<&str> = popup - .filtered_items() - .into_iter() - .filter_map(|item| match item { - CommandItem::Builtin(cmd) => Some(cmd.command()), - CommandItem::UserPrompt(_) => None, - }) - .collect(); - assert!( - !cmds.contains(&"collab"), - "expected '/collab' to be hidden when collaboration modes are disabled, got {cmds:?}" - ); - } - - #[test] - fn collab_command_visible_when_collaboration_modes_enabled() { - let mut popup = CommandPopup::new( - Vec::new(), - CommandPopupFlags { - skills_enabled: false, - collaboration_modes_enabled: true, - }, - ); - popup.on_composer_text_change("/collab".to_string()); - - match popup.selected_item() { - Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "collab"), - other => panic!("expected collab to be selected for exact match, got {other:?}"), - } - } -} diff --git a/codex-rs/tui2/src/bottom_pane/custom_prompt_view.rs b/codex-rs/tui2/src/bottom_pane/custom_prompt_view.rs deleted file mode 100644 index e9f0ee697f9..00000000000 --- a/codex-rs/tui2/src/bottom_pane/custom_prompt_view.rs +++ /dev/null @@ -1,247 +0,0 @@ -use crossterm::event::KeyCode; -use crossterm::event::KeyEvent; -use crossterm::event::KeyModifiers; -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -use ratatui::style::Stylize; -use ratatui::text::Line; -use ratatui::text::Span; -use ratatui::widgets::Clear; -use ratatui::widgets::Paragraph; -use ratatui::widgets::StatefulWidgetRef; -use ratatui::widgets::Widget; -use std::cell::RefCell; - -use crate::render::renderable::Renderable; - -use super::popup_consts::standard_popup_hint_line; - -use super::CancellationEvent; -use super::bottom_pane_view::BottomPaneView; -use super::textarea::TextArea; -use super::textarea::TextAreaState; - -/// Callback invoked when the user submits a custom prompt. -pub(crate) type PromptSubmitted = Box; - -/// Minimal multi-line text input view to collect custom review instructions. -pub(crate) struct CustomPromptView { - title: String, - placeholder: String, - context_label: Option, - on_submit: PromptSubmitted, - - // UI state - textarea: TextArea, - textarea_state: RefCell, - complete: bool, -} - -impl CustomPromptView { - pub(crate) fn new( - title: String, - placeholder: String, - context_label: Option, - on_submit: PromptSubmitted, - ) -> Self { - Self { - title, - placeholder, - context_label, - on_submit, - textarea: TextArea::new(), - textarea_state: RefCell::new(TextAreaState::default()), - complete: false, - } - } -} - -impl BottomPaneView for CustomPromptView { - fn handle_key_event(&mut self, key_event: KeyEvent) { - match key_event { - KeyEvent { - code: KeyCode::Esc, .. - } => { - self.on_ctrl_c(); - } - KeyEvent { - code: KeyCode::Enter, - modifiers: KeyModifiers::NONE, - .. - } => { - let text = self.textarea.text().trim().to_string(); - if !text.is_empty() { - (self.on_submit)(text); - self.complete = true; - } - } - KeyEvent { - code: KeyCode::Enter, - .. - } => { - self.textarea.input(key_event); - } - other => { - self.textarea.input(other); - } - } - } - - fn on_ctrl_c(&mut self) -> CancellationEvent { - self.complete = true; - CancellationEvent::Handled - } - - fn is_complete(&self) -> bool { - self.complete - } - - fn handle_paste(&mut self, pasted: String) -> bool { - if pasted.is_empty() { - return false; - } - self.textarea.insert_str(&pasted); - true - } -} - -impl Renderable for CustomPromptView { - fn desired_height(&self, width: u16) -> u16 { - let extra_top: u16 = if self.context_label.is_some() { 1 } else { 0 }; - 1u16 + extra_top + self.input_height(width) + 3u16 - } - - fn render(&self, area: Rect, buf: &mut Buffer) { - if area.height == 0 || area.width == 0 { - return; - } - - let input_height = self.input_height(area.width); - - // Title line - let title_area = Rect { - x: area.x, - y: area.y, - width: area.width, - height: 1, - }; - let title_spans: Vec> = vec![gutter(), self.title.clone().bold()]; - Paragraph::new(Line::from(title_spans)).render(title_area, buf); - - // Optional context line - let mut input_y = area.y.saturating_add(1); - if let Some(context_label) = &self.context_label { - let context_area = Rect { - x: area.x, - y: input_y, - width: area.width, - height: 1, - }; - let spans: Vec> = vec![gutter(), context_label.clone().cyan()]; - Paragraph::new(Line::from(spans)).render(context_area, buf); - input_y = input_y.saturating_add(1); - } - - // Input line - let input_area = Rect { - x: area.x, - y: input_y, - width: area.width, - height: input_height, - }; - if input_area.width >= 2 { - for row in 0..input_area.height { - Paragraph::new(Line::from(vec![gutter()])).render( - Rect { - x: input_area.x, - y: input_area.y.saturating_add(row), - width: 2, - height: 1, - }, - buf, - ); - } - - let text_area_height = input_area.height.saturating_sub(1); - if text_area_height > 0 { - if input_area.width > 2 { - let blank_rect = Rect { - x: input_area.x.saturating_add(2), - y: input_area.y, - width: input_area.width.saturating_sub(2), - height: 1, - }; - Clear.render(blank_rect, buf); - } - let textarea_rect = Rect { - x: input_area.x.saturating_add(2), - y: input_area.y.saturating_add(1), - width: input_area.width.saturating_sub(2), - height: text_area_height, - }; - let mut state = self.textarea_state.borrow_mut(); - StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state); - if self.textarea.text().is_empty() { - Paragraph::new(Line::from(self.placeholder.clone().dim())) - .render(textarea_rect, buf); - } - } - } - - let hint_blank_y = input_area.y.saturating_add(input_height); - if hint_blank_y < area.y.saturating_add(area.height) { - let blank_area = Rect { - x: area.x, - y: hint_blank_y, - width: area.width, - height: 1, - }; - Clear.render(blank_area, buf); - } - - let hint_y = hint_blank_y.saturating_add(1); - if hint_y < area.y.saturating_add(area.height) { - Paragraph::new(standard_popup_hint_line()).render( - Rect { - x: area.x, - y: hint_y, - width: area.width, - height: 1, - }, - buf, - ); - } - } - - fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { - if area.height < 2 || area.width <= 2 { - return None; - } - let text_area_height = self.input_height(area.width).saturating_sub(1); - if text_area_height == 0 { - return None; - } - let extra_offset: u16 = if self.context_label.is_some() { 1 } else { 0 }; - let top_line_count = 1u16 + extra_offset; - let textarea_rect = Rect { - x: area.x.saturating_add(2), - y: area.y.saturating_add(top_line_count).saturating_add(1), - width: area.width.saturating_sub(2), - height: text_area_height, - }; - let state = *self.textarea_state.borrow(); - self.textarea.cursor_pos_with_state(textarea_rect, state) - } -} - -impl CustomPromptView { - fn input_height(&self, width: u16) -> u16 { - let usable_width = width.saturating_sub(2); - let text_height = self.textarea.desired_height(usable_width).clamp(1, 8); - text_height.saturating_add(1).min(9) - } -} - -fn gutter() -> Span<'static> { - "▌ ".cyan() -} diff --git a/codex-rs/tui2/src/bottom_pane/experimental_features_view.rs b/codex-rs/tui2/src/bottom_pane/experimental_features_view.rs deleted file mode 100644 index 13f0f3a1d6e..00000000000 --- a/codex-rs/tui2/src/bottom_pane/experimental_features_view.rs +++ /dev/null @@ -1,293 +0,0 @@ -use crossterm::event::KeyCode; -use crossterm::event::KeyEvent; -use crossterm::event::KeyModifiers; -use ratatui::buffer::Buffer; -use ratatui::layout::Constraint; -use ratatui::layout::Layout; -use ratatui::layout::Rect; -use ratatui::style::Stylize; -use ratatui::text::Line; -use ratatui::widgets::Block; -use ratatui::widgets::Widget; - -use codex_core::features::Feature; - -use crate::app_event::AppEvent; -use crate::app_event_sender::AppEventSender; -use crate::key_hint; -use crate::render::Insets; -use crate::render::RectExt as _; -use crate::render::renderable::ColumnRenderable; -use crate::render::renderable::Renderable; -use crate::style::user_message_style; - -use super::CancellationEvent; -use super::bottom_pane_view::BottomPaneView; -use super::popup_consts::MAX_POPUP_ROWS; -use super::scroll_state::ScrollState; -use super::selection_popup_common::GenericDisplayRow; -use super::selection_popup_common::measure_rows_height; -use super::selection_popup_common::render_rows; - -pub(crate) struct BetaFeatureItem { - pub(crate) feature: Feature, - pub(crate) name: String, - pub(crate) description: String, - pub(crate) enabled: bool, -} - -pub(crate) struct ExperimentalFeaturesView { - features: Vec, - state: ScrollState, - complete: bool, - app_event_tx: AppEventSender, - header: Box, - footer_hint: Line<'static>, -} - -impl ExperimentalFeaturesView { - pub(crate) fn new(features: Vec, app_event_tx: AppEventSender) -> Self { - let mut header = ColumnRenderable::new(); - header.push(Line::from("Experimental features".bold())); - header.push(Line::from( - "Toggle beta features. Changes are saved to config.toml.".dim(), - )); - - let mut view = Self { - features, - state: ScrollState::new(), - complete: false, - app_event_tx, - header: Box::new(header), - footer_hint: experimental_popup_hint_line(), - }; - view.initialize_selection(); - view - } - - fn initialize_selection(&mut self) { - if self.visible_len() == 0 { - self.state.selected_idx = None; - } else if self.state.selected_idx.is_none() { - self.state.selected_idx = Some(0); - } - } - - fn visible_len(&self) -> usize { - self.features.len() - } - - fn build_rows(&self) -> Vec { - let mut rows = Vec::with_capacity(self.features.len()); - let selected_idx = self.state.selected_idx; - for (idx, item) in self.features.iter().enumerate() { - let prefix = if selected_idx == Some(idx) { - '›' - } else { - ' ' - }; - let marker = if item.enabled { 'x' } else { ' ' }; - let name = format!("{prefix} [{marker}] {}", item.name); - rows.push(GenericDisplayRow { - name, - display_shortcut: None, - match_indices: None, - description: Some(item.description.clone()), - wrap_indent: None, - }); - } - - rows - } - - fn move_up(&mut self) { - let len = self.visible_len(); - if len == 0 { - return; - } - self.state.move_up_wrap(len); - self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); - } - - fn move_down(&mut self) { - let len = self.visible_len(); - if len == 0 { - return; - } - self.state.move_down_wrap(len); - self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); - } - - fn toggle_selected(&mut self) { - let Some(selected_idx) = self.state.selected_idx else { - return; - }; - - if let Some(item) = self.features.get_mut(selected_idx) { - item.enabled = !item.enabled; - } - } - - fn rows_width(total_width: u16) -> u16 { - total_width.saturating_sub(2) - } -} - -impl BottomPaneView for ExperimentalFeaturesView { - fn handle_key_event(&mut self, key_event: KeyEvent) { - match key_event { - KeyEvent { - code: KeyCode::Up, .. - } - | KeyEvent { - code: KeyCode::Char('p'), - modifiers: KeyModifiers::CONTROL, - .. - } - | KeyEvent { - code: KeyCode::Char('\u{0010}'), - modifiers: KeyModifiers::NONE, - .. - } => self.move_up(), - KeyEvent { - code: KeyCode::Char('k'), - modifiers: KeyModifiers::NONE, - .. - } => self.move_up(), - KeyEvent { - code: KeyCode::Down, - .. - } - | KeyEvent { - code: KeyCode::Char('n'), - modifiers: KeyModifiers::CONTROL, - .. - } - | KeyEvent { - code: KeyCode::Char('\u{000e}'), - modifiers: KeyModifiers::NONE, - .. - } => self.move_down(), - KeyEvent { - code: KeyCode::Char('j'), - modifiers: KeyModifiers::NONE, - .. - } => self.move_down(), - KeyEvent { - code: KeyCode::Enter, - modifiers: KeyModifiers::NONE, - .. - } => self.toggle_selected(), - KeyEvent { - code: KeyCode::Esc, .. - } => { - self.on_ctrl_c(); - } - _ => {} - } - } - - fn is_complete(&self) -> bool { - self.complete - } - - fn on_ctrl_c(&mut self) -> CancellationEvent { - if !self.features.is_empty() { - let updates = self - .features - .iter() - .map(|item| (item.feature, item.enabled)) - .collect(); - self.app_event_tx - .send(AppEvent::UpdateFeatureFlags { updates }); - } - - self.complete = true; - CancellationEvent::Handled - } -} - -impl Renderable for ExperimentalFeaturesView { - fn render(&self, area: Rect, buf: &mut Buffer) { - if area.height == 0 || area.width == 0 { - return; - } - - let [content_area, footer_area] = - Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(area); - - Block::default() - .style(user_message_style()) - .render(content_area, buf); - - let header_height = self - .header - .desired_height(content_area.width.saturating_sub(4)); - let rows = self.build_rows(); - let rows_width = Self::rows_width(content_area.width); - let rows_height = measure_rows_height( - &rows, - &self.state, - MAX_POPUP_ROWS, - rows_width.saturating_add(1), - ); - let [header_area, _, list_area] = Layout::vertical([ - Constraint::Max(header_height), - Constraint::Max(1), - Constraint::Length(rows_height), - ]) - .areas(content_area.inset(Insets::vh(1, 2))); - - self.header.render(header_area, buf); - - if list_area.height > 0 { - let render_area = Rect { - x: list_area.x.saturating_sub(2), - y: list_area.y, - width: rows_width.max(1), - height: list_area.height, - }; - render_rows( - render_area, - buf, - &rows, - &self.state, - MAX_POPUP_ROWS, - " No experimental features available for now", - ); - } - - let hint_area = Rect { - x: footer_area.x + 2, - y: footer_area.y, - width: footer_area.width.saturating_sub(2), - height: footer_area.height, - }; - self.footer_hint.clone().dim().render(hint_area, buf); - } - - fn desired_height(&self, width: u16) -> u16 { - let rows = self.build_rows(); - let rows_width = Self::rows_width(width); - let rows_height = measure_rows_height( - &rows, - &self.state, - MAX_POPUP_ROWS, - rows_width.saturating_add(1), - ); - - let mut height = self.header.desired_height(width.saturating_sub(4)); - height = height.saturating_add(rows_height + 3); - height.saturating_add(1) - } -} - -fn experimental_popup_hint_line() -> Line<'static> { - Line::from(vec![ - "Press ".into(), - key_hint::plain(KeyCode::Enter).into(), - " to toggle or ".into(), - key_hint::plain(KeyCode::Esc).into(), - " to save for next conversation".into(), - ]) -} diff --git a/codex-rs/tui2/src/bottom_pane/feedback_view.rs b/codex-rs/tui2/src/bottom_pane/feedback_view.rs deleted file mode 100644 index 8fef8e79a1e..00000000000 --- a/codex-rs/tui2/src/bottom_pane/feedback_view.rs +++ /dev/null @@ -1,574 +0,0 @@ -use std::cell::RefCell; -use std::path::PathBuf; - -use crossterm::event::KeyCode; -use crossterm::event::KeyEvent; -use crossterm::event::KeyModifiers; -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -use ratatui::style::Stylize; -use ratatui::text::Line; -use ratatui::text::Span; -use ratatui::widgets::Clear; -use ratatui::widgets::Paragraph; -use ratatui::widgets::StatefulWidgetRef; -use ratatui::widgets::Widget; - -use crate::app_event::AppEvent; -use crate::app_event::FeedbackCategory; -use crate::app_event_sender::AppEventSender; -use crate::history_cell; -use crate::render::renderable::Renderable; -use codex_core::protocol::SessionSource; - -use super::CancellationEvent; -use super::bottom_pane_view::BottomPaneView; -use super::popup_consts::standard_popup_hint_line; -use super::textarea::TextArea; -use super::textarea::TextAreaState; - -const BASE_BUG_ISSUE_URL: &str = - "https://github.com/openai/codex/issues/new?template=2-bug-report.yml"; - -/// Minimal input overlay to collect an optional feedback note, then upload -/// both logs and rollout with classification + metadata. -pub(crate) struct FeedbackNoteView { - category: FeedbackCategory, - snapshot: codex_feedback::CodexLogSnapshot, - rollout_path: Option, - app_event_tx: AppEventSender, - include_logs: bool, - - // UI state - textarea: TextArea, - textarea_state: RefCell, - complete: bool, -} - -impl FeedbackNoteView { - pub(crate) fn new( - category: FeedbackCategory, - snapshot: codex_feedback::CodexLogSnapshot, - rollout_path: Option, - app_event_tx: AppEventSender, - include_logs: bool, - ) -> Self { - Self { - category, - snapshot, - rollout_path, - app_event_tx, - include_logs, - textarea: TextArea::new(), - textarea_state: RefCell::new(TextAreaState::default()), - complete: false, - } - } - - fn submit(&mut self) { - let note = self.textarea.text().trim().to_string(); - let reason_opt = if note.is_empty() { - None - } else { - Some(note.as_str()) - }; - let rollout_path_ref = self.rollout_path.as_deref(); - let classification = feedback_classification(self.category); - - let mut thread_id = self.snapshot.thread_id.clone(); - - let result = self.snapshot.upload_feedback( - classification, - reason_opt, - self.include_logs, - if self.include_logs { - rollout_path_ref - } else { - None - }, - Some(SessionSource::Cli), - ); - - match result { - Ok(()) => { - let prefix = if self.include_logs { - "• Feedback uploaded." - } else { - "• Feedback recorded (no logs)." - }; - let issue_url = issue_url_for_category(self.category, &thread_id); - let mut lines = vec![Line::from(match issue_url.as_ref() { - Some(_) => format!("{prefix} Please open an issue using the following URL:"), - None => format!("{prefix} Thanks for the feedback!"), - })]; - if let Some(url) = issue_url { - lines.extend([ - "".into(), - Line::from(vec![" ".into(), url.cyan().underlined()]), - "".into(), - Line::from(vec![ - " Or mention your thread ID ".into(), - std::mem::take(&mut thread_id).bold(), - " in an existing issue.".into(), - ]), - ]); - } else { - lines.extend([ - "".into(), - Line::from(vec![ - " Thread ID: ".into(), - std::mem::take(&mut thread_id).bold(), - ]), - ]); - } - self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::PlainHistoryCell::new(lines), - ))); - } - Err(e) => { - self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::new_error_event(format!("Failed to upload feedback: {e}")), - ))); - } - } - self.complete = true; - } -} - -impl BottomPaneView for FeedbackNoteView { - fn handle_key_event(&mut self, key_event: KeyEvent) { - match key_event { - KeyEvent { - code: KeyCode::Esc, .. - } => { - self.on_ctrl_c(); - } - KeyEvent { - code: KeyCode::Enter, - modifiers: KeyModifiers::NONE, - .. - } => { - self.submit(); - } - KeyEvent { - code: KeyCode::Enter, - .. - } => { - self.textarea.input(key_event); - } - other => { - self.textarea.input(other); - } - } - } - - fn on_ctrl_c(&mut self) -> CancellationEvent { - self.complete = true; - CancellationEvent::Handled - } - - fn is_complete(&self) -> bool { - self.complete - } - - fn handle_paste(&mut self, pasted: String) -> bool { - if pasted.is_empty() { - return false; - } - self.textarea.insert_str(&pasted); - true - } -} - -impl Renderable for FeedbackNoteView { - fn desired_height(&self, width: u16) -> u16 { - 1u16 + self.input_height(width) + 3u16 - } - - fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { - if area.height < 2 || area.width <= 2 { - return None; - } - let text_area_height = self.input_height(area.width).saturating_sub(1); - if text_area_height == 0 { - return None; - } - let top_line_count = 1u16; // title only - let textarea_rect = Rect { - x: area.x.saturating_add(2), - y: area.y.saturating_add(top_line_count).saturating_add(1), - width: area.width.saturating_sub(2), - height: text_area_height, - }; - let state = *self.textarea_state.borrow(); - self.textarea.cursor_pos_with_state(textarea_rect, state) - } - - fn render(&self, area: Rect, buf: &mut Buffer) { - if area.height == 0 || area.width == 0 { - return; - } - - let (title, placeholder) = feedback_title_and_placeholder(self.category); - let input_height = self.input_height(area.width); - - // Title line - let title_area = Rect { - x: area.x, - y: area.y, - width: area.width, - height: 1, - }; - let title_spans: Vec> = vec![gutter(), title.bold()]; - Paragraph::new(Line::from(title_spans)).render(title_area, buf); - - // Input line - let input_area = Rect { - x: area.x, - y: area.y.saturating_add(1), - width: area.width, - height: input_height, - }; - if input_area.width >= 2 { - for row in 0..input_area.height { - Paragraph::new(Line::from(vec![gutter()])).render( - Rect { - x: input_area.x, - y: input_area.y.saturating_add(row), - width: 2, - height: 1, - }, - buf, - ); - } - - let text_area_height = input_area.height.saturating_sub(1); - if text_area_height > 0 { - if input_area.width > 2 { - let blank_rect = Rect { - x: input_area.x.saturating_add(2), - y: input_area.y, - width: input_area.width.saturating_sub(2), - height: 1, - }; - Clear.render(blank_rect, buf); - } - let textarea_rect = Rect { - x: input_area.x.saturating_add(2), - y: input_area.y.saturating_add(1), - width: input_area.width.saturating_sub(2), - height: text_area_height, - }; - let mut state = self.textarea_state.borrow_mut(); - StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state); - if self.textarea.text().is_empty() { - Paragraph::new(Line::from(placeholder.dim())).render(textarea_rect, buf); - } - } - } - - let hint_blank_y = input_area.y.saturating_add(input_height); - if hint_blank_y < area.y.saturating_add(area.height) { - let blank_area = Rect { - x: area.x, - y: hint_blank_y, - width: area.width, - height: 1, - }; - Clear.render(blank_area, buf); - } - - let hint_y = hint_blank_y.saturating_add(1); - if hint_y < area.y.saturating_add(area.height) { - Paragraph::new(standard_popup_hint_line()).render( - Rect { - x: area.x, - y: hint_y, - width: area.width, - height: 1, - }, - buf, - ); - } - } -} - -impl FeedbackNoteView { - fn input_height(&self, width: u16) -> u16 { - let usable_width = width.saturating_sub(2); - let text_height = self.textarea.desired_height(usable_width).clamp(1, 8); - text_height.saturating_add(1).min(9) - } -} - -fn gutter() -> Span<'static> { - "▌ ".cyan() -} - -fn feedback_title_and_placeholder(category: FeedbackCategory) -> (String, String) { - match category { - FeedbackCategory::BadResult => ( - "Tell us more (bad result)".to_string(), - "(optional) Write a short description to help us further".to_string(), - ), - FeedbackCategory::GoodResult => ( - "Tell us more (good result)".to_string(), - "(optional) Write a short description to help us further".to_string(), - ), - FeedbackCategory::Bug => ( - "Tell us more (bug)".to_string(), - "(optional) Write a short description to help us further".to_string(), - ), - FeedbackCategory::Other => ( - "Tell us more (other)".to_string(), - "(optional) Write a short description to help us further".to_string(), - ), - } -} - -fn feedback_classification(category: FeedbackCategory) -> &'static str { - match category { - FeedbackCategory::BadResult => "bad_result", - FeedbackCategory::GoodResult => "good_result", - FeedbackCategory::Bug => "bug", - FeedbackCategory::Other => "other", - } -} - -fn issue_url_for_category(category: FeedbackCategory, thread_id: &str) -> Option { - match category { - FeedbackCategory::Bug | FeedbackCategory::BadResult | FeedbackCategory::Other => Some( - format!("{BASE_BUG_ISSUE_URL}&steps=Uploaded%20thread:%20{thread_id}"), - ), - FeedbackCategory::GoodResult => None, - } -} - -// Build the selection popup params for feedback categories. -pub(crate) fn feedback_selection_params( - app_event_tx: AppEventSender, -) -> super::SelectionViewParams { - super::SelectionViewParams { - title: Some("How was this?".to_string()), - items: vec![ - make_feedback_item( - app_event_tx.clone(), - "bug", - "Crash, error message, hang, or broken UI/behavior.", - FeedbackCategory::Bug, - ), - make_feedback_item( - app_event_tx.clone(), - "bad result", - "Output was off-target, incorrect, incomplete, or unhelpful.", - FeedbackCategory::BadResult, - ), - make_feedback_item( - app_event_tx.clone(), - "good result", - "Helpful, correct, high‑quality, or delightful result worth celebrating.", - FeedbackCategory::GoodResult, - ), - make_feedback_item( - app_event_tx, - "other", - "Slowness, feature suggestion, UX feedback, or anything else.", - FeedbackCategory::Other, - ), - ], - ..Default::default() - } -} - -/// Build the selection popup params shown when feedback is disabled. -pub(crate) fn feedback_disabled_params() -> super::SelectionViewParams { - super::SelectionViewParams { - title: Some("Sending feedback is disabled".to_string()), - subtitle: Some("This action is disabled by configuration.".to_string()), - footer_hint: Some(standard_popup_hint_line()), - items: vec![super::SelectionItem { - name: "Close".to_string(), - dismiss_on_select: true, - ..Default::default() - }], - ..Default::default() - } -} - -fn make_feedback_item( - app_event_tx: AppEventSender, - name: &str, - description: &str, - category: FeedbackCategory, -) -> super::SelectionItem { - let action: super::SelectionAction = Box::new(move |_sender: &AppEventSender| { - app_event_tx.send(AppEvent::OpenFeedbackConsent { category }); - }); - super::SelectionItem { - name: name.to_string(), - description: Some(description.to_string()), - actions: vec![action], - dismiss_on_select: true, - ..Default::default() - } -} - -/// Build the upload consent popup params for a given feedback category. -pub(crate) fn feedback_upload_consent_params( - app_event_tx: AppEventSender, - category: FeedbackCategory, - rollout_path: Option, -) -> super::SelectionViewParams { - use super::popup_consts::standard_popup_hint_line; - let yes_action: super::SelectionAction = Box::new({ - let tx = app_event_tx.clone(); - move |sender: &AppEventSender| { - let _ = sender; - tx.send(AppEvent::OpenFeedbackNote { - category, - include_logs: true, - }); - } - }); - - let no_action: super::SelectionAction = Box::new({ - let tx = app_event_tx; - move |sender: &AppEventSender| { - let _ = sender; - tx.send(AppEvent::OpenFeedbackNote { - category, - include_logs: false, - }); - } - }); - - // Build header listing files that would be sent if user consents. - let mut header_lines: Vec> = vec![ - Line::from("Upload logs?".bold()).into(), - Line::from("").into(), - Line::from("The following files will be sent:".dim()).into(), - Line::from(vec![" • ".into(), "codex-logs.log".into()]).into(), - ]; - if let Some(path) = rollout_path.as_deref() - && let Some(name) = path.file_name().map(|s| s.to_string_lossy().to_string()) - { - header_lines.push(Line::from(vec![" • ".into(), name.into()]).into()); - } - - super::SelectionViewParams { - footer_hint: Some(standard_popup_hint_line()), - items: vec![ - super::SelectionItem { - name: "Yes".to_string(), - description: Some( - "Share the current Codex session logs with the team for troubleshooting." - .to_string(), - ), - actions: vec![yes_action], - dismiss_on_select: true, - ..Default::default() - }, - super::SelectionItem { - name: "No".to_string(), - description: Some("".to_string()), - actions: vec![no_action], - dismiss_on_select: true, - ..Default::default() - }, - ], - header: Box::new(crate::render::renderable::ColumnRenderable::with( - header_lines, - )), - ..Default::default() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::app_event::AppEvent; - use crate::app_event_sender::AppEventSender; - - fn render(view: &FeedbackNoteView, width: u16) -> String { - let height = view.desired_height(width); - let area = Rect::new(0, 0, width, height); - let mut buf = Buffer::empty(area); - view.render(area, &mut buf); - - let mut lines: Vec = (0..area.height) - .map(|row| { - let mut line = String::new(); - for col in 0..area.width { - let symbol = buf[(area.x + col, area.y + row)].symbol(); - if symbol.is_empty() { - line.push(' '); - } else { - line.push_str(symbol); - } - } - line.trim_end().to_string() - }) - .collect(); - - while lines.first().is_some_and(|l| l.trim().is_empty()) { - lines.remove(0); - } - while lines.last().is_some_and(|l| l.trim().is_empty()) { - lines.pop(); - } - lines.join("\n") - } - - fn make_view(category: FeedbackCategory) -> FeedbackNoteView { - let (tx_raw, _rx) = tokio::sync::mpsc::unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let snapshot = codex_feedback::CodexFeedback::new().snapshot(None); - FeedbackNoteView::new(category, snapshot, None, tx, true) - } - - #[test] - fn feedback_view_bad_result() { - let view = make_view(FeedbackCategory::BadResult); - let rendered = render(&view, 60); - insta::assert_snapshot!("feedback_view_bad_result", rendered); - } - - #[test] - fn feedback_view_good_result() { - let view = make_view(FeedbackCategory::GoodResult); - let rendered = render(&view, 60); - insta::assert_snapshot!("feedback_view_good_result", rendered); - } - - #[test] - fn feedback_view_bug() { - let view = make_view(FeedbackCategory::Bug); - let rendered = render(&view, 60); - insta::assert_snapshot!("feedback_view_bug", rendered); - } - - #[test] - fn feedback_view_other() { - let view = make_view(FeedbackCategory::Other); - let rendered = render(&view, 60); - insta::assert_snapshot!("feedback_view_other", rendered); - } - - #[test] - fn issue_url_available_for_bug_bad_result_and_other() { - let bug_url = issue_url_for_category(FeedbackCategory::Bug, "thread-1"); - assert!( - bug_url - .as_deref() - .is_some_and(|url| url.contains("template=2-bug-report")) - ); - - let bad_result_url = issue_url_for_category(FeedbackCategory::BadResult, "thread-2"); - assert!(bad_result_url.is_some()); - - let other_url = issue_url_for_category(FeedbackCategory::Other, "thread-3"); - assert!(other_url.is_some()); - - assert!(issue_url_for_category(FeedbackCategory::GoodResult, "t").is_none()); - } -} diff --git a/codex-rs/tui2/src/bottom_pane/file_search_popup.rs b/codex-rs/tui2/src/bottom_pane/file_search_popup.rs deleted file mode 100644 index 064e4f01370..00000000000 --- a/codex-rs/tui2/src/bottom_pane/file_search_popup.rs +++ /dev/null @@ -1,154 +0,0 @@ -use codex_file_search::FileMatch; -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -use ratatui::widgets::WidgetRef; - -use crate::render::Insets; -use crate::render::RectExt; - -use super::popup_consts::MAX_POPUP_ROWS; -use super::scroll_state::ScrollState; -use super::selection_popup_common::GenericDisplayRow; -use super::selection_popup_common::render_rows; - -/// Visual state for the file-search popup. -pub(crate) struct FileSearchPopup { - /// Query corresponding to the `matches` currently shown. - display_query: String, - /// Latest query typed by the user. May differ from `display_query` when - /// a search is still in-flight. - pending_query: String, - /// When `true` we are still waiting for results for `pending_query`. - waiting: bool, - /// Cached matches; paths relative to the search dir. - matches: Vec, - /// Shared selection/scroll state. - state: ScrollState, -} - -impl FileSearchPopup { - pub(crate) fn new() -> Self { - Self { - display_query: String::new(), - pending_query: String::new(), - waiting: true, - matches: Vec::new(), - state: ScrollState::new(), - } - } - - /// Update the query and reset state to *waiting*. - pub(crate) fn set_query(&mut self, query: &str) { - if query == self.pending_query { - return; - } - - // Determine if current matches are still relevant. - let keep_existing = query.starts_with(&self.display_query); - - self.pending_query.clear(); - self.pending_query.push_str(query); - - self.waiting = true; // waiting for new results - - if !keep_existing { - self.matches.clear(); - self.state.reset(); - } - } - - /// Put the popup into an "idle" state used for an empty query (just "@"). - /// Shows a hint instead of matches until the user types more characters. - pub(crate) fn set_empty_prompt(&mut self) { - self.display_query.clear(); - self.pending_query.clear(); - self.waiting = false; - self.matches.clear(); - // Reset selection/scroll state when showing the empty prompt. - self.state.reset(); - } - - /// Replace matches when a `FileSearchResult` arrives. - /// Replace matches. Only applied when `query` matches `pending_query`. - pub(crate) fn set_matches(&mut self, query: &str, matches: Vec) { - if query != self.pending_query { - return; // stale - } - - self.display_query = query.to_string(); - self.matches = matches; - self.waiting = false; - let len = self.matches.len(); - self.state.clamp_selection(len); - self.state.ensure_visible(len, len.min(MAX_POPUP_ROWS)); - } - - /// Move selection cursor up. - pub(crate) fn move_up(&mut self) { - let len = self.matches.len(); - self.state.move_up_wrap(len); - self.state.ensure_visible(len, len.min(MAX_POPUP_ROWS)); - } - - /// Move selection cursor down. - pub(crate) fn move_down(&mut self) { - let len = self.matches.len(); - self.state.move_down_wrap(len); - self.state.ensure_visible(len, len.min(MAX_POPUP_ROWS)); - } - - pub(crate) fn selected_match(&self) -> Option<&str> { - self.state - .selected_idx - .and_then(|idx| self.matches.get(idx)) - .map(|file_match| file_match.path.as_str()) - } - - pub(crate) fn calculate_required_height(&self) -> u16 { - // Row count depends on whether we already have matches. If no matches - // yet (e.g. initial search or query with no results) reserve a single - // row so the popup is still visible. When matches are present we show - // up to MAX_RESULTS regardless of the waiting flag so the list - // remains stable while a newer search is in-flight. - - self.matches.len().clamp(1, MAX_POPUP_ROWS) as u16 - } -} - -impl WidgetRef for &FileSearchPopup { - fn render_ref(&self, area: Rect, buf: &mut Buffer) { - // Convert matches to GenericDisplayRow, translating indices to usize at the UI boundary. - let rows_all: Vec = if self.matches.is_empty() { - Vec::new() - } else { - self.matches - .iter() - .map(|m| GenericDisplayRow { - name: m.path.clone(), - match_indices: m - .indices - .as_ref() - .map(|v| v.iter().map(|&i| i as usize).collect()), - display_shortcut: None, - description: None, - wrap_indent: None, - }) - .collect() - }; - - let empty_message = if self.waiting { - "loading..." - } else { - "no matches" - }; - - render_rows( - area.inset(Insets::tlbr(0, 2, 0, 0)), - buf, - &rows_all, - &self.state, - MAX_POPUP_ROWS, - empty_message, - ); - } -} diff --git a/codex-rs/tui2/src/bottom_pane/footer.rs b/codex-rs/tui2/src/bottom_pane/footer.rs deleted file mode 100644 index fe5a13dc48f..00000000000 --- a/codex-rs/tui2/src/bottom_pane/footer.rs +++ /dev/null @@ -1,831 +0,0 @@ -//! The bottom-pane footer renders transient hints and context indicators. -//! -//! The footer is pure rendering: it formats `FooterProps` into `Line`s without mutating any state. -//! It intentionally does not decide *which* footer content should be shown; that is owned by the -//! `ChatComposer` (which selects a `FooterMode`) and by higher-level state machines like -//! `ChatWidget` (which decides when quit/interrupt is allowed). -//! -//! Some footer content is time-based rather than event-based, such as the "press again to quit" -//! hint. The owning widgets schedule redraws so time-based hints can expire even if the UI is -//! otherwise idle. -#[cfg(target_os = "linux")] -use crate::clipboard_paste::is_probably_wsl; -use crate::key_hint; -use crate::key_hint::KeyBinding; -use crate::render::line_utils::prefix_lines; -use crate::status::format_tokens_compact; -use crate::transcript_copy_action::TranscriptCopyFeedback; -use crate::ui_consts::FOOTER_INDENT_COLS; -use crossterm::event::KeyCode; -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -use ratatui::style::Stylize; -use ratatui::text::Line; -use ratatui::text::Span; -use ratatui::widgets::Paragraph; -use ratatui::widgets::Widget; - -/// The rendering inputs for the footer area under the composer. -/// -/// Callers are expected to construct `FooterProps` from higher-level state (`ChatComposer`, -/// `BottomPane`, and `ChatWidget`) and pass it to `render_footer`. The footer treats these values as -/// authoritative and does not attempt to infer missing state (for example, it does not query -/// whether a task is running). -#[derive(Clone, Copy, Debug)] -pub(crate) struct FooterProps { - pub(crate) mode: FooterMode, - pub(crate) esc_backtrack_hint: bool, - pub(crate) use_shift_enter_hint: bool, - pub(crate) is_task_running: bool, - pub(crate) steer_enabled: bool, - pub(crate) collaboration_modes_enabled: bool, - /// Which key the user must press again to quit. - /// - /// This is rendered when `mode` is `FooterMode::QuitShortcutReminder`. - pub(crate) quit_shortcut_key: KeyBinding, - pub(crate) context_window_percent: Option, - pub(crate) context_window_used_tokens: Option, - pub(crate) transcript_scrolled: bool, - pub(crate) transcript_selection_active: bool, - pub(crate) transcript_scroll_position: Option<(usize, usize)>, - pub(crate) transcript_copy_selection_key: KeyBinding, - pub(crate) transcript_copy_feedback: Option, -} - -/// Selects which footer content is rendered. -/// -/// The current mode is owned by `ChatComposer`, which may override it based on transient state -/// (for example, showing `QuitShortcutReminder` only while its timer is active). -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum FooterMode { - /// Transient "press again to quit" reminder (Ctrl+C/Ctrl+D). - QuitShortcutReminder, - ShortcutSummary, - ShortcutOverlay, - EscHint, - ContextOnly, -} - -pub(crate) fn toggle_shortcut_mode(current: FooterMode, ctrl_c_hint: bool) -> FooterMode { - if ctrl_c_hint && matches!(current, FooterMode::QuitShortcutReminder) { - return current; - } - - match current { - FooterMode::ShortcutOverlay | FooterMode::QuitShortcutReminder => { - FooterMode::ShortcutSummary - } - _ => FooterMode::ShortcutOverlay, - } -} - -pub(crate) fn esc_hint_mode(current: FooterMode, is_task_running: bool) -> FooterMode { - if is_task_running { - current - } else { - FooterMode::EscHint - } -} - -pub(crate) fn reset_mode_after_activity(current: FooterMode) -> FooterMode { - match current { - FooterMode::EscHint - | FooterMode::ShortcutOverlay - | FooterMode::QuitShortcutReminder - | FooterMode::ContextOnly => FooterMode::ShortcutSummary, - other => other, - } -} - -pub(crate) fn footer_height(props: FooterProps) -> u16 { - footer_lines(props).len() as u16 -} - -pub(crate) fn render_footer(area: Rect, buf: &mut Buffer, props: FooterProps) { - Paragraph::new(prefix_lines( - footer_lines(props), - " ".repeat(FOOTER_INDENT_COLS).into(), - " ".repeat(FOOTER_INDENT_COLS).into(), - )) - .render(area, buf); -} - -pub(crate) fn inset_footer_hint_area(mut area: Rect) -> Rect { - if area.width > 2 { - area.x += 2; - area.width = area.width.saturating_sub(2); - } - area -} - -pub(crate) fn render_footer_hint_items(area: Rect, buf: &mut Buffer, items: &[(String, String)]) { - if items.is_empty() { - return; - } - - let mut spans = Vec::with_capacity(items.len() * 4); - for (idx, (key, label)) in items.iter().enumerate() { - spans.push(" ".into()); - spans.push(key.clone().bold()); - spans.push(format!(" {label}").into()); - if idx + 1 != items.len() { - spans.push(" ".into()); - } - } - Line::from(spans).render(inset_footer_hint_area(area), buf); -} - -fn footer_lines(props: FooterProps) -> Vec> { - fn apply_copy_feedback(lines: &mut [Line<'static>], feedback: Option) { - let Some(line) = lines.first_mut() else { - return; - }; - let Some(feedback) = feedback else { - return; - }; - - line.push_span(" · ".dim()); - match feedback { - TranscriptCopyFeedback::Copied => line.push_span("Copied".green().bold()), - TranscriptCopyFeedback::Failed => line.push_span("Copy failed".red().bold()), - } - } - - // Show the context indicator on the left, appended after the primary hint - // (e.g., "? for shortcuts"). Keep it visible even when typing (i.e., when - // the shortcut hint is hidden). Hide it only for the multi-line - // ShortcutOverlay. - let mut lines = match props.mode { - FooterMode::QuitShortcutReminder => { - vec![quit_shortcut_reminder_line(props.quit_shortcut_key)] - } - FooterMode::ShortcutSummary => { - let mut line = context_window_line( - props.context_window_percent, - props.context_window_used_tokens, - ); - line.push_span(" · ".dim()); - line.extend(vec![ - key_hint::plain(KeyCode::Char('?')).into(), - " for shortcuts".dim(), - ]); - if props.transcript_scrolled { - line.push_span(" · ".dim()); - line.push_span(key_hint::plain(KeyCode::PageUp)); - line.push_span("/"); - line.push_span(key_hint::plain(KeyCode::PageDown)); - line.push_span(" scroll".dim()); - line.push_span(" · ".dim()); - line.push_span(key_hint::plain(KeyCode::Home)); - line.push_span("/"); - line.push_span(key_hint::plain(KeyCode::End)); - line.push_span(" jump".dim()); - if let Some((current, total)) = props.transcript_scroll_position { - line.push_span(" · ".dim()); - line.push_span(Span::from(format!("{current}/{total}")).dim()); - } - } - if props.transcript_selection_active { - line.push_span(" · ".dim()); - line.push_span(props.transcript_copy_selection_key); - line.push_span(" copy selection".dim()); - } - vec![line] - } - FooterMode::ShortcutOverlay => { - #[cfg(target_os = "linux")] - let is_wsl = is_probably_wsl(); - #[cfg(not(target_os = "linux"))] - let is_wsl = false; - - let state = ShortcutsState { - use_shift_enter_hint: props.use_shift_enter_hint, - esc_backtrack_hint: props.esc_backtrack_hint, - is_wsl, - collaboration_modes_enabled: props.collaboration_modes_enabled, - }; - shortcut_overlay_lines(state) - } - FooterMode::EscHint => vec![esc_hint_line(props.esc_backtrack_hint)], - FooterMode::ContextOnly => { - let mut line = context_window_line( - props.context_window_percent, - props.context_window_used_tokens, - ); - if props.is_task_running && props.steer_enabled { - line.push_span(" · ".dim()); - line.push_span(key_hint::plain(KeyCode::Tab)); - line.push_span(" to queue message".dim()); - } - vec![line] - } - }; - apply_copy_feedback(&mut lines, props.transcript_copy_feedback); - lines -} - -#[derive(Clone, Copy, Debug)] -struct ShortcutsState { - use_shift_enter_hint: bool, - esc_backtrack_hint: bool, - is_wsl: bool, - collaboration_modes_enabled: bool, -} - -fn quit_shortcut_reminder_line(key: KeyBinding) -> Line<'static> { - Line::from(vec![key.into(), " again to quit".into()]).dim() -} - -fn esc_hint_line(esc_backtrack_hint: bool) -> Line<'static> { - let esc = key_hint::plain(KeyCode::Esc); - if esc_backtrack_hint { - Line::from(vec![esc.into(), " again to edit previous message".into()]).dim() - } else { - Line::from(vec![ - esc.into(), - " ".into(), - esc.into(), - " to edit previous message".into(), - ]) - .dim() - } -} - -fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { - let mut commands = Line::from(""); - let mut shell_commands = Line::from(""); - let mut newline = Line::from(""); - let mut queue_message_tab = Line::from(""); - let mut file_paths = Line::from(""); - let mut paste_image = Line::from(""); - let mut edit_previous = Line::from(""); - let mut quit = Line::from(""); - let mut show_transcript = Line::from(""); - let mut change_mode = Line::from(""); - - for descriptor in SHORTCUTS { - if let Some(text) = descriptor.overlay_entry(state) { - match descriptor.id { - ShortcutId::Commands => commands = text, - ShortcutId::ShellCommands => shell_commands = text, - ShortcutId::InsertNewline => newline = text, - ShortcutId::QueueMessageTab => queue_message_tab = text, - ShortcutId::FilePaths => file_paths = text, - ShortcutId::PasteImage => paste_image = text, - ShortcutId::EditPrevious => edit_previous = text, - ShortcutId::Quit => quit = text, - ShortcutId::ShowTranscript => show_transcript = text, - ShortcutId::ChangeMode => change_mode = text, - } - } - } - - let mut ordered = vec![ - commands, - shell_commands, - newline, - queue_message_tab, - file_paths, - paste_image, - edit_previous, - quit, - ]; - if change_mode.width() > 0 { - ordered.push(change_mode); - } - ordered.push(Line::from("")); - ordered.push(show_transcript); - - build_columns(ordered) -} - -fn build_columns(entries: Vec>) -> Vec> { - if entries.is_empty() { - return Vec::new(); - } - - const COLUMNS: usize = 2; - const COLUMN_PADDING: [usize; COLUMNS] = [4, 4]; - const COLUMN_GAP: usize = 4; - - let rows = entries.len().div_ceil(COLUMNS); - let target_len = rows * COLUMNS; - let mut entries = entries; - if entries.len() < target_len { - entries.extend(std::iter::repeat_n( - Line::from(""), - target_len - entries.len(), - )); - } - - let mut column_widths = [0usize; COLUMNS]; - - for (idx, entry) in entries.iter().enumerate() { - let column = idx % COLUMNS; - column_widths[column] = column_widths[column].max(entry.width()); - } - - for (idx, width) in column_widths.iter_mut().enumerate() { - *width += COLUMN_PADDING[idx]; - } - - entries - .chunks(COLUMNS) - .map(|chunk| { - let mut line = Line::from(""); - for (col, entry) in chunk.iter().enumerate() { - line.extend(entry.spans.clone()); - if col < COLUMNS - 1 { - let target_width = column_widths[col]; - let padding = target_width.saturating_sub(entry.width()) + COLUMN_GAP; - line.push_span(Span::from(" ".repeat(padding))); - } - } - line.dim() - }) - .collect() -} - -fn context_window_line(percent: Option, used_tokens: Option) -> Line<'static> { - if let Some(percent) = percent { - let percent = percent.clamp(0, 100); - return Line::from(vec![Span::from(format!("{percent}% context left")).dim()]); - } - - if let Some(tokens) = used_tokens { - let used_fmt = format_tokens_compact(tokens); - return Line::from(vec![Span::from(format!("{used_fmt} used")).dim()]); - } - - Line::from(vec![Span::from("100% context left").dim()]) -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum ShortcutId { - Commands, - ShellCommands, - InsertNewline, - QueueMessageTab, - FilePaths, - PasteImage, - EditPrevious, - Quit, - ShowTranscript, - ChangeMode, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct ShortcutBinding { - key: KeyBinding, - condition: DisplayCondition, -} - -impl ShortcutBinding { - fn matches(&self, state: ShortcutsState) -> bool { - self.condition.matches(state) - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum DisplayCondition { - Always, - WhenShiftEnterHint, - WhenNotShiftEnterHint, - WhenUnderWSL, - WhenCollaborationModesEnabled, -} - -impl DisplayCondition { - fn matches(self, state: ShortcutsState) -> bool { - match self { - DisplayCondition::Always => true, - DisplayCondition::WhenShiftEnterHint => state.use_shift_enter_hint, - DisplayCondition::WhenNotShiftEnterHint => !state.use_shift_enter_hint, - DisplayCondition::WhenUnderWSL => state.is_wsl, - DisplayCondition::WhenCollaborationModesEnabled => state.collaboration_modes_enabled, - } - } -} - -struct ShortcutDescriptor { - id: ShortcutId, - bindings: &'static [ShortcutBinding], - prefix: &'static str, - label: &'static str, -} - -impl ShortcutDescriptor { - fn binding_for(&self, state: ShortcutsState) -> Option<&'static ShortcutBinding> { - self.bindings.iter().find(|binding| binding.matches(state)) - } - - fn overlay_entry(&self, state: ShortcutsState) -> Option> { - let binding = self.binding_for(state)?; - let mut line = Line::from(vec![self.prefix.into(), binding.key.into()]); - match self.id { - ShortcutId::EditPrevious => { - if state.esc_backtrack_hint { - line.push_span(" again to edit previous message"); - } else { - line.extend(vec![ - " ".into(), - key_hint::plain(KeyCode::Esc).into(), - " to edit previous message".into(), - ]); - } - } - _ => line.push_span(self.label), - }; - Some(line) - } -} - -const SHORTCUTS: &[ShortcutDescriptor] = &[ - ShortcutDescriptor { - id: ShortcutId::Commands, - bindings: &[ShortcutBinding { - key: key_hint::plain(KeyCode::Char('/')), - condition: DisplayCondition::Always, - }], - prefix: "", - label: " for commands", - }, - ShortcutDescriptor { - id: ShortcutId::ShellCommands, - bindings: &[ShortcutBinding { - key: key_hint::plain(KeyCode::Char('!')), - condition: DisplayCondition::Always, - }], - prefix: "", - label: " for shell commands", - }, - ShortcutDescriptor { - id: ShortcutId::InsertNewline, - bindings: &[ - ShortcutBinding { - key: key_hint::shift(KeyCode::Enter), - condition: DisplayCondition::WhenShiftEnterHint, - }, - ShortcutBinding { - key: key_hint::ctrl(KeyCode::Char('j')), - condition: DisplayCondition::WhenNotShiftEnterHint, - }, - ], - prefix: "", - label: " for newline", - }, - ShortcutDescriptor { - id: ShortcutId::QueueMessageTab, - bindings: &[ShortcutBinding { - key: key_hint::plain(KeyCode::Tab), - condition: DisplayCondition::Always, - }], - prefix: "", - label: " to queue message", - }, - ShortcutDescriptor { - id: ShortcutId::FilePaths, - bindings: &[ShortcutBinding { - key: key_hint::plain(KeyCode::Char('@')), - condition: DisplayCondition::Always, - }], - prefix: "", - label: " for file paths", - }, - ShortcutDescriptor { - id: ShortcutId::PasteImage, - // Show Ctrl+Alt+V when running under WSL (terminals often intercept plain - // Ctrl+V); otherwise fall back to Ctrl+V. - bindings: &[ - ShortcutBinding { - key: key_hint::ctrl_alt(KeyCode::Char('v')), - condition: DisplayCondition::WhenUnderWSL, - }, - ShortcutBinding { - key: key_hint::ctrl(KeyCode::Char('v')), - condition: DisplayCondition::Always, - }, - ], - prefix: "", - label: " to paste images", - }, - ShortcutDescriptor { - id: ShortcutId::EditPrevious, - bindings: &[ShortcutBinding { - key: key_hint::plain(KeyCode::Esc), - condition: DisplayCondition::Always, - }], - prefix: "", - label: "", - }, - ShortcutDescriptor { - id: ShortcutId::Quit, - bindings: &[ShortcutBinding { - key: key_hint::ctrl(KeyCode::Char('c')), - condition: DisplayCondition::Always, - }], - prefix: "", - label: " to exit", - }, - ShortcutDescriptor { - id: ShortcutId::ShowTranscript, - bindings: &[ShortcutBinding { - key: key_hint::ctrl(KeyCode::Char('t')), - condition: DisplayCondition::Always, - }], - prefix: "", - label: " to view transcript", - }, - ShortcutDescriptor { - id: ShortcutId::ChangeMode, - bindings: &[ShortcutBinding { - key: key_hint::shift(KeyCode::Tab), - condition: DisplayCondition::WhenCollaborationModesEnabled, - }], - prefix: "", - label: " to change mode", - }, -]; - -#[cfg(test)] -mod tests { - use super::*; - use insta::assert_snapshot; - use ratatui::Terminal; - use ratatui::backend::TestBackend; - - fn snapshot_footer(name: &str, props: FooterProps) { - let height = footer_height(props).max(1); - let mut terminal = Terminal::new(TestBackend::new(80, height)).unwrap(); - terminal - .draw(|f| { - let area = Rect::new(0, 0, f.area().width, height); - render_footer(area, f.buffer_mut(), props); - }) - .unwrap(); - assert_snapshot!(name, terminal.backend()); - } - - #[test] - fn footer_snapshots() { - snapshot_footer( - "footer_shortcuts_default", - FooterProps { - mode: FooterMode::ShortcutSummary, - esc_backtrack_hint: false, - use_shift_enter_hint: false, - is_task_running: false, - steer_enabled: false, - collaboration_modes_enabled: false, - quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, - transcript_scrolled: false, - transcript_selection_active: false, - transcript_scroll_position: None, - transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), - transcript_copy_feedback: None, - }, - ); - - snapshot_footer( - "footer_shortcuts_transcript_scrolled_and_selection", - FooterProps { - mode: FooterMode::ShortcutSummary, - esc_backtrack_hint: false, - use_shift_enter_hint: false, - is_task_running: false, - steer_enabled: false, - collaboration_modes_enabled: false, - quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, - transcript_scrolled: true, - transcript_selection_active: true, - transcript_scroll_position: Some((3, 42)), - transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), - transcript_copy_feedback: None, - }, - ); - - snapshot_footer( - "footer_shortcuts_shift_and_esc", - FooterProps { - mode: FooterMode::ShortcutOverlay, - esc_backtrack_hint: true, - use_shift_enter_hint: true, - is_task_running: false, - steer_enabled: false, - collaboration_modes_enabled: false, - quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, - transcript_scrolled: false, - transcript_selection_active: false, - transcript_scroll_position: None, - transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), - transcript_copy_feedback: None, - }, - ); - - snapshot_footer( - "footer_shortcuts_collaboration_modes_enabled", - FooterProps { - mode: FooterMode::ShortcutOverlay, - esc_backtrack_hint: true, - use_shift_enter_hint: true, - is_task_running: false, - steer_enabled: false, - collaboration_modes_enabled: true, - quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, - transcript_scrolled: false, - transcript_selection_active: false, - transcript_scroll_position: None, - transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), - transcript_copy_feedback: None, - }, - ); - - snapshot_footer( - "footer_ctrl_c_quit_idle", - FooterProps { - mode: FooterMode::QuitShortcutReminder, - esc_backtrack_hint: false, - use_shift_enter_hint: false, - is_task_running: false, - steer_enabled: false, - collaboration_modes_enabled: false, - quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, - transcript_scrolled: false, - transcript_selection_active: false, - transcript_scroll_position: None, - transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), - transcript_copy_feedback: None, - }, - ); - - snapshot_footer( - "footer_ctrl_c_quit_running", - FooterProps { - mode: FooterMode::QuitShortcutReminder, - esc_backtrack_hint: false, - use_shift_enter_hint: false, - is_task_running: true, - steer_enabled: false, - collaboration_modes_enabled: false, - quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, - transcript_scrolled: false, - transcript_selection_active: false, - transcript_scroll_position: None, - transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), - transcript_copy_feedback: None, - }, - ); - - snapshot_footer( - "footer_esc_hint_idle", - FooterProps { - mode: FooterMode::EscHint, - esc_backtrack_hint: false, - use_shift_enter_hint: false, - is_task_running: false, - steer_enabled: false, - collaboration_modes_enabled: false, - quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, - transcript_scrolled: false, - transcript_selection_active: false, - transcript_scroll_position: None, - transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), - transcript_copy_feedback: None, - }, - ); - - snapshot_footer( - "footer_esc_hint_primed", - FooterProps { - mode: FooterMode::EscHint, - esc_backtrack_hint: true, - use_shift_enter_hint: false, - is_task_running: false, - steer_enabled: false, - collaboration_modes_enabled: false, - quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, - transcript_scrolled: false, - transcript_selection_active: false, - transcript_scroll_position: None, - transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), - transcript_copy_feedback: None, - }, - ); - - snapshot_footer( - "footer_shortcuts_context_running", - FooterProps { - mode: FooterMode::ShortcutSummary, - esc_backtrack_hint: false, - use_shift_enter_hint: false, - is_task_running: true, - steer_enabled: false, - collaboration_modes_enabled: false, - quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: Some(72), - context_window_used_tokens: None, - transcript_scrolled: false, - transcript_selection_active: false, - transcript_scroll_position: None, - transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), - transcript_copy_feedback: None, - }, - ); - - snapshot_footer( - "footer_context_tokens_used", - FooterProps { - mode: FooterMode::ShortcutSummary, - esc_backtrack_hint: false, - use_shift_enter_hint: false, - is_task_running: false, - steer_enabled: false, - collaboration_modes_enabled: false, - quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: Some(123_456), - transcript_scrolled: false, - transcript_selection_active: false, - transcript_scroll_position: None, - transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), - transcript_copy_feedback: None, - }, - ); - - snapshot_footer( - "footer_context_only_queue_hint_disabled", - FooterProps { - mode: FooterMode::ContextOnly, - esc_backtrack_hint: false, - use_shift_enter_hint: false, - is_task_running: true, - steer_enabled: false, - collaboration_modes_enabled: false, - quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, - transcript_scrolled: false, - transcript_selection_active: false, - transcript_scroll_position: None, - transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), - transcript_copy_feedback: None, - }, - ); - - snapshot_footer( - "footer_context_only_queue_hint_enabled", - FooterProps { - mode: FooterMode::ContextOnly, - esc_backtrack_hint: false, - use_shift_enter_hint: false, - is_task_running: true, - steer_enabled: true, - collaboration_modes_enabled: false, - quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, - transcript_scrolled: false, - transcript_selection_active: false, - transcript_scroll_position: None, - transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), - transcript_copy_feedback: None, - }, - ); - - snapshot_footer( - "footer_copy_feedback_copied", - FooterProps { - mode: FooterMode::ShortcutSummary, - esc_backtrack_hint: false, - use_shift_enter_hint: false, - is_task_running: false, - steer_enabled: false, - collaboration_modes_enabled: false, - quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, - transcript_scrolled: false, - transcript_selection_active: false, - transcript_scroll_position: None, - transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), - transcript_copy_feedback: Some(TranscriptCopyFeedback::Copied), - }, - ); - } -} diff --git a/codex-rs/tui2/src/bottom_pane/list_selection_view.rs b/codex-rs/tui2/src/bottom_pane/list_selection_view.rs deleted file mode 100644 index 27c7dc4233e..00000000000 --- a/codex-rs/tui2/src/bottom_pane/list_selection_view.rs +++ /dev/null @@ -1,873 +0,0 @@ -use crossterm::event::KeyCode; -use crossterm::event::KeyEvent; -use crossterm::event::KeyModifiers; -use itertools::Itertools as _; -use ratatui::buffer::Buffer; -use ratatui::layout::Constraint; -use ratatui::layout::Layout; -use ratatui::layout::Rect; -use ratatui::style::Stylize; -use ratatui::text::Line; -use ratatui::text::Span; -use ratatui::widgets::Block; -use ratatui::widgets::Paragraph; -use ratatui::widgets::Widget; - -use super::selection_popup_common::wrap_styled_line; -use crate::app_event_sender::AppEventSender; -use crate::key_hint::KeyBinding; -use crate::render::Insets; -use crate::render::RectExt as _; -use crate::render::renderable::ColumnRenderable; -use crate::render::renderable::Renderable; -use crate::style::user_message_style; - -use super::CancellationEvent; -use super::bottom_pane_view::BottomPaneView; -use super::popup_consts::MAX_POPUP_ROWS; -use super::scroll_state::ScrollState; -use super::selection_popup_common::GenericDisplayRow; -use super::selection_popup_common::measure_rows_height; -use super::selection_popup_common::render_rows; -use unicode_width::UnicodeWidthStr; - -/// One selectable item in the generic selection list. -pub(crate) type SelectionAction = Box; - -#[derive(Default)] -pub(crate) struct SelectionItem { - pub name: String, - pub display_shortcut: Option, - pub description: Option, - pub selected_description: Option, - pub is_current: bool, - pub is_default: bool, - pub actions: Vec, - pub dismiss_on_select: bool, - pub search_value: Option, -} - -pub(crate) struct SelectionViewParams { - pub title: Option, - pub subtitle: Option, - pub footer_note: Option>, - pub footer_hint: Option>, - pub items: Vec, - pub is_searchable: bool, - pub search_placeholder: Option, - pub header: Box, - pub initial_selected_idx: Option, -} - -impl Default for SelectionViewParams { - fn default() -> Self { - Self { - title: None, - subtitle: None, - footer_note: None, - footer_hint: None, - items: Vec::new(), - is_searchable: false, - search_placeholder: None, - header: Box::new(()), - initial_selected_idx: None, - } - } -} - -pub(crate) struct ListSelectionView { - footer_note: Option>, - footer_hint: Option>, - items: Vec, - state: ScrollState, - complete: bool, - app_event_tx: AppEventSender, - is_searchable: bool, - search_query: String, - search_placeholder: Option, - filtered_indices: Vec, - last_selected_actual_idx: Option, - header: Box, - initial_selected_idx: Option, -} - -impl ListSelectionView { - pub fn new(params: SelectionViewParams, app_event_tx: AppEventSender) -> Self { - let mut header = params.header; - if params.title.is_some() || params.subtitle.is_some() { - let title = params.title.map(|title| Line::from(title.bold())); - let subtitle = params.subtitle.map(|subtitle| Line::from(subtitle.dim())); - header = Box::new(ColumnRenderable::with([ - header, - Box::new(title), - Box::new(subtitle), - ])); - } - let mut s = Self { - footer_note: params.footer_note, - footer_hint: params.footer_hint, - items: params.items, - state: ScrollState::new(), - complete: false, - app_event_tx, - is_searchable: params.is_searchable, - search_query: String::new(), - search_placeholder: if params.is_searchable { - params.search_placeholder - } else { - None - }, - filtered_indices: Vec::new(), - last_selected_actual_idx: None, - header, - initial_selected_idx: params.initial_selected_idx, - }; - s.apply_filter(); - s - } - - fn visible_len(&self) -> usize { - self.filtered_indices.len() - } - - fn max_visible_rows(len: usize) -> usize { - MAX_POPUP_ROWS.min(len.max(1)) - } - - fn apply_filter(&mut self) { - let previously_selected = self - .state - .selected_idx - .and_then(|visible_idx| self.filtered_indices.get(visible_idx).copied()) - .or_else(|| { - (!self.is_searchable) - .then(|| self.items.iter().position(|item| item.is_current)) - .flatten() - }) - .or_else(|| self.initial_selected_idx.take()); - - if self.is_searchable && !self.search_query.is_empty() { - let query_lower = self.search_query.to_lowercase(); - self.filtered_indices = self - .items - .iter() - .positions(|item| { - item.search_value - .as_ref() - .is_some_and(|v| v.to_lowercase().contains(&query_lower)) - }) - .collect(); - } else { - self.filtered_indices = (0..self.items.len()).collect(); - } - - let len = self.filtered_indices.len(); - self.state.selected_idx = self - .state - .selected_idx - .and_then(|visible_idx| { - self.filtered_indices - .get(visible_idx) - .and_then(|idx| self.filtered_indices.iter().position(|cur| cur == idx)) - }) - .or_else(|| { - previously_selected.and_then(|actual_idx| { - self.filtered_indices - .iter() - .position(|idx| *idx == actual_idx) - }) - }) - .or_else(|| (len > 0).then_some(0)); - - let visible = Self::max_visible_rows(len); - self.state.clamp_selection(len); - self.state.ensure_visible(len, visible); - } - - fn build_rows(&self) -> Vec { - self.filtered_indices - .iter() - .enumerate() - .filter_map(|(visible_idx, actual_idx)| { - self.items.get(*actual_idx).map(|item| { - let is_selected = self.state.selected_idx == Some(visible_idx); - let prefix = if is_selected { '›' } else { ' ' }; - let name = item.name.as_str(); - let marker = if item.is_current { - " (current)" - } else if item.is_default { - " (default)" - } else { - "" - }; - let name_with_marker = format!("{name}{marker}"); - let n = visible_idx + 1; - let wrap_prefix = if self.is_searchable { - // The number keys don't work when search is enabled (since we let the - // numbers be used for the search query). - format!("{prefix} ") - } else { - format!("{prefix} {n}. ") - }; - let wrap_prefix_width = UnicodeWidthStr::width(wrap_prefix.as_str()); - let display_name = format!("{wrap_prefix}{name_with_marker}"); - let description = is_selected - .then(|| item.selected_description.clone()) - .flatten() - .or_else(|| item.description.clone()); - let wrap_indent = description.is_none().then_some(wrap_prefix_width); - GenericDisplayRow { - name: display_name, - display_shortcut: item.display_shortcut, - match_indices: None, - description, - wrap_indent, - } - }) - }) - .collect() - } - - fn move_up(&mut self) { - let len = self.visible_len(); - self.state.move_up_wrap(len); - let visible = Self::max_visible_rows(len); - self.state.ensure_visible(len, visible); - } - - fn move_down(&mut self) { - let len = self.visible_len(); - self.state.move_down_wrap(len); - let visible = Self::max_visible_rows(len); - self.state.ensure_visible(len, visible); - } - - fn accept(&mut self) { - if let Some(idx) = self.state.selected_idx - && let Some(actual_idx) = self.filtered_indices.get(idx) - && let Some(item) = self.items.get(*actual_idx) - { - self.last_selected_actual_idx = Some(*actual_idx); - for act in &item.actions { - act(&self.app_event_tx); - } - if item.dismiss_on_select { - self.complete = true; - } - } else { - self.complete = true; - } - } - - #[cfg(test)] - pub(crate) fn set_search_query(&mut self, query: String) { - self.search_query = query; - self.apply_filter(); - } - - pub(crate) fn take_last_selected_index(&mut self) -> Option { - self.last_selected_actual_idx.take() - } - - fn rows_width(total_width: u16) -> u16 { - total_width.saturating_sub(2) - } -} - -impl BottomPaneView for ListSelectionView { - fn handle_key_event(&mut self, key_event: KeyEvent) { - match key_event { - // Some terminals (or configurations) send Control key chords as - // C0 control characters without reporting the CONTROL modifier. - // Handle fallbacks for Ctrl-P/N here so navigation works everywhere. - KeyEvent { - code: KeyCode::Up, .. - } - | KeyEvent { - code: KeyCode::Char('p'), - modifiers: KeyModifiers::CONTROL, - .. - } - | KeyEvent { - code: KeyCode::Char('\u{0010}'), - modifiers: KeyModifiers::NONE, - .. - } /* ^P */ => self.move_up(), - KeyEvent { - code: KeyCode::Char('k'), - modifiers: KeyModifiers::NONE, - .. - } if !self.is_searchable => self.move_up(), - KeyEvent { - code: KeyCode::Down, - .. - } - | KeyEvent { - code: KeyCode::Char('n'), - modifiers: KeyModifiers::CONTROL, - .. - } - | KeyEvent { - code: KeyCode::Char('\u{000e}'), - modifiers: KeyModifiers::NONE, - .. - } /* ^N */ => self.move_down(), - KeyEvent { - code: KeyCode::Char('j'), - modifiers: KeyModifiers::NONE, - .. - } if !self.is_searchable => self.move_down(), - KeyEvent { - code: KeyCode::Backspace, - .. - } if self.is_searchable => { - self.search_query.pop(); - self.apply_filter(); - } - KeyEvent { - code: KeyCode::Esc, .. - } => { - self.on_ctrl_c(); - } - KeyEvent { - code: KeyCode::Char(c), - modifiers, - .. - } if self.is_searchable - && !modifiers.contains(KeyModifiers::CONTROL) - && !modifiers.contains(KeyModifiers::ALT) => - { - self.search_query.push(c); - self.apply_filter(); - } - KeyEvent { - code: KeyCode::Char(c), - modifiers, - .. - } if !self.is_searchable - && !modifiers.contains(KeyModifiers::CONTROL) - && !modifiers.contains(KeyModifiers::ALT) => - { - if let Some(idx) = c - .to_digit(10) - .map(|d| d as usize) - .and_then(|d| d.checked_sub(1)) - && idx < self.items.len() - { - self.state.selected_idx = Some(idx); - self.accept(); - } - } - KeyEvent { - code: KeyCode::Enter, - modifiers: KeyModifiers::NONE, - .. - } => self.accept(), - _ => {} - } - } - - fn is_complete(&self) -> bool { - self.complete - } - - fn on_ctrl_c(&mut self) -> CancellationEvent { - self.complete = true; - CancellationEvent::Handled - } -} - -impl Renderable for ListSelectionView { - fn desired_height(&self, width: u16) -> u16 { - // Measure wrapped height for up to MAX_POPUP_ROWS items at the given width. - // Build the same display rows used by the renderer so wrapping math matches. - let rows = self.build_rows(); - let rows_width = Self::rows_width(width); - let rows_height = measure_rows_height( - &rows, - &self.state, - MAX_POPUP_ROWS, - rows_width.saturating_add(1), - ); - - // Subtract 4 for the padding on the left and right of the header. - let mut height = self.header.desired_height(width.saturating_sub(4)); - height = height.saturating_add(rows_height + 3); - if self.is_searchable { - height = height.saturating_add(1); - } - if let Some(note) = &self.footer_note { - let note_width = width.saturating_sub(2); - let note_lines = wrap_styled_line(note, note_width); - height = height.saturating_add(note_lines.len() as u16); - } - if self.footer_hint.is_some() { - height = height.saturating_add(1); - } - height - } - - fn render(&self, area: Rect, buf: &mut Buffer) { - if area.height == 0 || area.width == 0 { - return; - } - - let note_width = area.width.saturating_sub(2); - let note_lines = self - .footer_note - .as_ref() - .map(|note| wrap_styled_line(note, note_width)); - let note_height = note_lines.as_ref().map_or(0, |lines| lines.len() as u16); - let footer_rows = note_height + u16::from(self.footer_hint.is_some()); - let [content_area, footer_area] = - Layout::vertical([Constraint::Fill(1), Constraint::Length(footer_rows)]).areas(area); - - Block::default() - .style(user_message_style()) - .render(content_area, buf); - - let header_height = self - .header - // Subtract 4 for the padding on the left and right of the header. - .desired_height(content_area.width.saturating_sub(4)); - let rows = self.build_rows(); - let rows_width = Self::rows_width(content_area.width); - let rows_height = measure_rows_height( - &rows, - &self.state, - MAX_POPUP_ROWS, - rows_width.saturating_add(1), - ); - let [header_area, _, search_area, list_area] = Layout::vertical([ - Constraint::Max(header_height), - Constraint::Max(1), - Constraint::Length(if self.is_searchable { 1 } else { 0 }), - Constraint::Length(rows_height), - ]) - .areas(content_area.inset(Insets::vh(1, 2))); - - if header_area.height < header_height { - let [header_area, elision_area] = - Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(header_area); - self.header.render(header_area, buf); - Paragraph::new(vec![ - Line::from(format!("[… {header_height} lines] ctrl + a view all")).dim(), - ]) - .render(elision_area, buf); - } else { - self.header.render(header_area, buf); - } - - if self.is_searchable { - Line::from(self.search_query.clone()).render(search_area, buf); - let query_span: Span<'static> = if self.search_query.is_empty() { - self.search_placeholder - .as_ref() - .map(|placeholder| placeholder.clone().dim()) - .unwrap_or_else(|| "".into()) - } else { - self.search_query.clone().into() - }; - Line::from(query_span).render(search_area, buf); - } - - if list_area.height > 0 { - let render_area = Rect { - x: list_area.x.saturating_sub(2), - y: list_area.y, - width: rows_width.max(1), - height: list_area.height, - }; - render_rows( - render_area, - buf, - &rows, - &self.state, - render_area.height as usize, - "no matches", - ); - } - - if footer_area.height > 0 { - let [note_area, hint_area] = Layout::vertical([ - Constraint::Length(note_height), - Constraint::Length(if self.footer_hint.is_some() { 1 } else { 0 }), - ]) - .areas(footer_area); - - if let Some(lines) = note_lines { - let note_area = Rect { - x: note_area.x + 2, - y: note_area.y, - width: note_area.width.saturating_sub(2), - height: note_area.height, - }; - for (idx, line) in lines.iter().enumerate() { - if idx as u16 >= note_area.height { - break; - } - let line_area = Rect { - x: note_area.x, - y: note_area.y + idx as u16, - width: note_area.width, - height: 1, - }; - line.clone().render(line_area, buf); - } - } - - if let Some(hint) = &self.footer_hint { - let hint_area = Rect { - x: hint_area.x + 2, - y: hint_area.y, - width: hint_area.width.saturating_sub(2), - height: hint_area.height, - }; - hint.clone().dim().render(hint_area, buf); - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::app_event::AppEvent; - use crate::bottom_pane::popup_consts::standard_popup_hint_line; - use insta::assert_snapshot; - use ratatui::layout::Rect; - use tokio::sync::mpsc::unbounded_channel; - - fn make_selection_view(subtitle: Option<&str>) -> ListSelectionView { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let items = vec![ - SelectionItem { - name: "Read Only".to_string(), - description: Some("Codex can read files".to_string()), - is_current: true, - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "Full Access".to_string(), - description: Some("Codex can edit files".to_string()), - is_current: false, - dismiss_on_select: true, - ..Default::default() - }, - ]; - ListSelectionView::new( - SelectionViewParams { - title: Some("Select Approval Mode".to_string()), - subtitle: subtitle.map(str::to_string), - footer_hint: Some(standard_popup_hint_line()), - items, - ..Default::default() - }, - tx, - ) - } - - fn render_lines(view: &ListSelectionView) -> String { - render_lines_with_width(view, 48) - } - - fn render_lines_with_width(view: &ListSelectionView, width: u16) -> String { - let height = view.desired_height(width); - let area = Rect::new(0, 0, width, height); - let mut buf = Buffer::empty(area); - view.render(area, &mut buf); - - let lines: Vec = (0..area.height) - .map(|row| { - let mut line = String::new(); - for col in 0..area.width { - let symbol = buf[(area.x + col, area.y + row)].symbol(); - if symbol.is_empty() { - line.push(' '); - } else { - line.push_str(symbol); - } - } - line - }) - .collect(); - lines.join("\n") - } - - #[test] - fn renders_blank_line_between_title_and_items_without_subtitle() { - let view = make_selection_view(None); - assert_snapshot!( - "list_selection_spacing_without_subtitle", - render_lines(&view) - ); - } - - #[test] - fn renders_blank_line_between_subtitle_and_items() { - let view = make_selection_view(Some("Switch between Codex approval presets")); - assert_snapshot!("list_selection_spacing_with_subtitle", render_lines(&view)); - } - - #[test] - fn snapshot_footer_note_wraps() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let items = vec![SelectionItem { - name: "Read Only".to_string(), - description: Some("Codex can read files".to_string()), - is_current: true, - dismiss_on_select: true, - ..Default::default() - }]; - let footer_note = Line::from(vec![ - "Note: ".dim(), - "Use /setup-elevated-sandbox".cyan(), - " to allow network access.".dim(), - ]); - let view = ListSelectionView::new( - SelectionViewParams { - title: Some("Select Approval Mode".to_string()), - footer_note: Some(footer_note), - footer_hint: Some(standard_popup_hint_line()), - items, - ..Default::default() - }, - tx, - ); - assert_snapshot!( - "list_selection_footer_note_wraps", - render_lines_with_width(&view, 40) - ); - } - - #[test] - fn renders_search_query_line_when_enabled() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let items = vec![SelectionItem { - name: "Read Only".to_string(), - description: Some("Codex can read files".to_string()), - is_current: false, - dismiss_on_select: true, - ..Default::default() - }]; - let mut view = ListSelectionView::new( - SelectionViewParams { - title: Some("Select Approval Mode".to_string()), - footer_hint: Some(standard_popup_hint_line()), - items, - is_searchable: true, - search_placeholder: Some("Type to search branches".to_string()), - ..Default::default() - }, - tx, - ); - view.set_search_query("filters".to_string()); - - let lines = render_lines(&view); - assert!( - lines.contains("filters"), - "expected search query line to include rendered query, got {lines:?}" - ); - } - - #[test] - fn wraps_long_option_without_overflowing_columns() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let items = vec![ - SelectionItem { - name: "Yes, proceed".to_string(), - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "Yes, and don't ask again for commands that start with `python -mpre_commit run --files eslint-plugin/no-mixed-const-enum-exports.js`".to_string(), - dismiss_on_select: true, - ..Default::default() - }, - ]; - let view = ListSelectionView::new( - SelectionViewParams { - title: Some("Approval".to_string()), - items, - ..Default::default() - }, - tx, - ); - - let rendered = render_lines_with_width(&view, 60); - let command_line = rendered - .lines() - .find(|line| line.contains("python -mpre_commit run")) - .expect("rendered lines should include wrapped command"); - assert!( - command_line.starts_with(" `python -mpre_commit run"), - "wrapped command line should align under the numbered prefix:\n{rendered}" - ); - assert!( - rendered.contains("eslint-plugin/no-") - && rendered.contains("mixed-const-enum-exports.js"), - "long command should not be truncated even when wrapped:\n{rendered}" - ); - } - - #[test] - fn width_changes_do_not_hide_rows() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let items = vec![ - SelectionItem { - name: "gpt-5.1-codex".to_string(), - description: Some( - "Optimized for Codex. Balance of reasoning quality and coding ability." - .to_string(), - ), - is_current: true, - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "gpt-5.1-codex-mini".to_string(), - description: Some( - "Optimized for Codex. Cheaper, faster, but less capable.".to_string(), - ), - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "gpt-4.1-codex".to_string(), - description: Some( - "Legacy model. Use when you need compatibility with older automations." - .to_string(), - ), - dismiss_on_select: true, - ..Default::default() - }, - ]; - let view = ListSelectionView::new( - SelectionViewParams { - title: Some("Select Model and Effort".to_string()), - items, - ..Default::default() - }, - tx, - ); - let mut missing: Vec = Vec::new(); - for width in 60..=90 { - let rendered = render_lines_with_width(&view, width); - if !rendered.contains("3.") { - missing.push(width); - } - } - assert!( - missing.is_empty(), - "third option missing at widths {missing:?}" - ); - } - - #[test] - fn narrow_width_keeps_all_rows_visible() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let desc = "x".repeat(10); - let items: Vec = (1..=3) - .map(|idx| SelectionItem { - name: format!("Item {idx}"), - description: Some(desc.clone()), - dismiss_on_select: true, - ..Default::default() - }) - .collect(); - let view = ListSelectionView::new( - SelectionViewParams { - title: Some("Debug".to_string()), - items, - ..Default::default() - }, - tx, - ); - let rendered = render_lines_with_width(&view, 24); - assert!( - rendered.contains("3."), - "third option missing for width 24:\n{rendered}" - ); - } - - #[test] - fn snapshot_model_picker_width_80() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let items = vec![ - SelectionItem { - name: "gpt-5.1-codex".to_string(), - description: Some( - "Optimized for Codex. Balance of reasoning quality and coding ability." - .to_string(), - ), - is_current: true, - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "gpt-5.1-codex-mini".to_string(), - description: Some( - "Optimized for Codex. Cheaper, faster, but less capable.".to_string(), - ), - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "gpt-4.1-codex".to_string(), - description: Some( - "Legacy model. Use when you need compatibility with older automations." - .to_string(), - ), - dismiss_on_select: true, - ..Default::default() - }, - ]; - let view = ListSelectionView::new( - SelectionViewParams { - title: Some("Select Model and Effort".to_string()), - items, - ..Default::default() - }, - tx, - ); - assert_snapshot!( - "list_selection_model_picker_width_80", - render_lines_with_width(&view, 80) - ); - } - - #[test] - fn snapshot_narrow_width_preserves_third_option() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let desc = "x".repeat(10); - let items: Vec = (1..=3) - .map(|idx| SelectionItem { - name: format!("Item {idx}"), - description: Some(desc.clone()), - dismiss_on_select: true, - ..Default::default() - }) - .collect(); - let view = ListSelectionView::new( - SelectionViewParams { - title: Some("Debug".to_string()), - items, - ..Default::default() - }, - tx, - ); - assert_snapshot!( - "list_selection_narrow_width_preserves_rows", - render_lines_with_width(&view, 24) - ); - } -} diff --git a/codex-rs/tui2/src/bottom_pane/mod.rs b/codex-rs/tui2/src/bottom_pane/mod.rs deleted file mode 100644 index ec5df34f660..00000000000 --- a/codex-rs/tui2/src/bottom_pane/mod.rs +++ /dev/null @@ -1,1050 +0,0 @@ -//! The bottom pane is the interactive footer of the chat UI. -//! -//! The pane owns the [`ChatComposer`] (editable prompt input) and a stack of transient -//! [`BottomPaneView`]s (popups/modals) that temporarily replace the composer for focused -//! interactions like selection lists. -//! -//! Input routing is layered: `BottomPane` decides which local surface receives a key (view vs -//! composer), while higher-level intent such as "interrupt" or "quit" is decided by the parent -//! widget (`ChatWidget`). This split matters for Ctrl+C/Ctrl+D: the bottom pane gives the active -//! view the first chance to consume Ctrl+C (typically to dismiss itself), and `ChatWidget` may -//! treat an unhandled Ctrl+C as an interrupt or as the first press of a double-press quit -//! shortcut. -//! -//! Some UI is time-based rather than input-based, such as the transient "press again to quit" -//! hint. The pane schedules redraws so those hints can expire even when the UI is otherwise idle. -use std::path::PathBuf; - -use crate::app_event_sender::AppEventSender; -use crate::bottom_pane::queued_user_messages::QueuedUserMessages; -use crate::key_hint; -use crate::key_hint::KeyBinding; -use crate::render::renderable::FlexRenderable; -use crate::render::renderable::Renderable; -use crate::render::renderable::RenderableItem; -use crate::tui::FrameRequester; -use bottom_pane_view::BottomPaneView; -use codex_core::features::Features; -use codex_core::skills::model::SkillMetadata; -use codex_file_search::FileMatch; -use codex_protocol::user_input::TextElement; -use crossterm::event::KeyCode; -use crossterm::event::KeyEvent; -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -use ratatui::text::Line; -use std::time::Duration; - -mod approval_overlay; -pub(crate) use approval_overlay::ApprovalOverlay; -pub(crate) use approval_overlay::ApprovalRequest; -mod bottom_pane_view; - -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct LocalImageAttachment { - pub(crate) placeholder: String, - pub(crate) path: PathBuf, -} -mod chat_composer; -mod chat_composer_history; -mod command_popup; -pub mod custom_prompt_view; -mod experimental_features_view; -mod file_search_popup; -mod footer; -mod list_selection_view; -mod prompt_args; -mod skill_popup; -pub(crate) use list_selection_view::SelectionViewParams; -mod feedback_view; -pub(crate) use feedback_view::feedback_disabled_params; -pub(crate) use feedback_view::feedback_selection_params; -pub(crate) use feedback_view::feedback_upload_consent_params; -mod paste_burst; -pub mod popup_consts; -mod queued_user_messages; -mod scroll_state; -mod selection_popup_common; -mod textarea; -pub(crate) use feedback_view::FeedbackNoteView; - -/// How long the "press again to quit" hint stays visible. -/// -/// This is shared between: -/// - `ChatWidget`: arming the double-press quit shortcut. -/// - `BottomPane`/`ChatComposer`: rendering and expiring the footer hint. -/// -/// Keeping a single value ensures Ctrl+C and Ctrl+D behave identically. -pub(crate) const QUIT_SHORTCUT_TIMEOUT: Duration = Duration::from_secs(1); - -/// Whether Ctrl+C/Ctrl+D require a second press to quit. -/// -/// This UX experiment was enabled by default, but requiring a double press to quit feels janky in -/// practice (especially for users accustomed to shells and other TUIs). Disable it for now while we -/// rethink a better quit/interrupt design. -pub(crate) const DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED: bool = false; - -/// The result of offering a cancellation key to a bottom-pane surface. -/// -/// This is primarily used for Ctrl+C routing: active views can consume the key to dismiss -/// themselves, and the caller can decide what higher-level action (if any) to take when the key is -/// not handled locally. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum CancellationEvent { - Handled, - NotHandled, -} - -pub(crate) use chat_composer::ChatComposer; -pub(crate) use chat_composer::InputResult; -use codex_protocol::custom_prompts::CustomPrompt; - -use crate::status_indicator_widget::StatusIndicatorWidget; -pub(crate) use experimental_features_view::BetaFeatureItem; -pub(crate) use experimental_features_view::ExperimentalFeaturesView; -pub(crate) use list_selection_view::SelectionAction; -pub(crate) use list_selection_view::SelectionItem; - -/// Pane displayed in the lower half of the chat UI. -/// -/// This is the owning container for the prompt input (`ChatComposer`) and the view stack -/// (`BottomPaneView`). It performs local input routing and renders time-based hints, while leaving -/// process-level decisions (quit, interrupt, shutdown) to `ChatWidget`. -pub(crate) struct BottomPane { - /// Composer is retained even when a BottomPaneView is displayed so the - /// input state is retained when the view is closed. - composer: ChatComposer, - - /// Stack of views displayed instead of the composer (e.g. popups/modals). - view_stack: Vec>, - - app_event_tx: AppEventSender, - frame_requester: FrameRequester, - - has_input_focus: bool, - is_task_running: bool, - esc_backtrack_hint: bool, - animations_enabled: bool, - - /// Inline status indicator shown above the composer while a task is running. - status: Option, - /// Queued user messages to show above the composer while a turn is running. - queued_user_messages: QueuedUserMessages, - context_window_percent: Option, - context_window_used_tokens: Option, -} - -pub(crate) struct BottomPaneParams { - pub(crate) app_event_tx: AppEventSender, - pub(crate) frame_requester: FrameRequester, - pub(crate) has_input_focus: bool, - pub(crate) enhanced_keys_supported: bool, - pub(crate) placeholder_text: String, - pub(crate) disable_paste_burst: bool, - pub(crate) animations_enabled: bool, - pub(crate) skills: Option>, -} - -impl BottomPane { - pub fn new(params: BottomPaneParams) -> Self { - let BottomPaneParams { - app_event_tx, - frame_requester, - has_input_focus, - enhanced_keys_supported, - placeholder_text, - disable_paste_burst, - animations_enabled, - skills, - } = params; - let mut composer = ChatComposer::new( - has_input_focus, - app_event_tx.clone(), - enhanced_keys_supported, - placeholder_text, - disable_paste_burst, - ); - composer.set_skill_mentions(skills); - - Self { - composer, - view_stack: Vec::new(), - app_event_tx, - frame_requester, - has_input_focus, - is_task_running: false, - status: None, - queued_user_messages: QueuedUserMessages::new(), - esc_backtrack_hint: false, - animations_enabled, - context_window_percent: None, - context_window_used_tokens: None, - } - } - - pub fn set_skills(&mut self, skills: Option>) { - self.composer.set_skill_mentions(skills); - self.request_redraw(); - } - - pub fn set_steer_enabled(&mut self, enabled: bool) { - self.composer.set_steer_enabled(enabled); - } - - pub fn set_collaboration_modes_enabled(&mut self, enabled: bool) { - self.composer.set_collaboration_modes_enabled(enabled); - self.request_redraw(); - } - - pub fn status_widget(&self) -> Option<&StatusIndicatorWidget> { - self.status.as_ref() - } - - pub fn skills(&self) -> Option<&Vec> { - self.composer.skills() - } - - #[cfg(test)] - pub(crate) fn context_window_percent(&self) -> Option { - self.context_window_percent - } - - #[cfg(test)] - pub(crate) fn context_window_used_tokens(&self) -> Option { - self.context_window_used_tokens - } - - fn active_view(&self) -> Option<&dyn BottomPaneView> { - self.view_stack.last().map(std::convert::AsRef::as_ref) - } - - fn push_view(&mut self, view: Box) { - self.view_stack.push(view); - self.request_redraw(); - } - - /// Forward a key event to the active view or the composer. - pub fn handle_key_event(&mut self, key_event: KeyEvent) -> InputResult { - // If a modal/view is active, handle it here; otherwise forward to composer. - if let Some(view) = self.view_stack.last_mut() { - if key_event.code == KeyCode::Esc - && matches!(view.on_ctrl_c(), CancellationEvent::Handled) - && view.is_complete() - { - self.view_stack.pop(); - self.on_active_view_complete(); - } else { - view.handle_key_event(key_event); - if view.is_complete() { - self.view_stack.clear(); - self.on_active_view_complete(); - } - } - self.request_redraw(); - InputResult::None - } else { - // If a task is running and a status line is visible, allow Esc to - // send an interrupt even while the composer has focus. - if matches!(key_event.code, crossterm::event::KeyCode::Esc) - && self.is_task_running - && let Some(status) = &self.status - { - // Send Op::Interrupt - status.interrupt(); - self.request_redraw(); - return InputResult::None; - } - let (input_result, needs_redraw) = self.composer.handle_key_event(key_event); - if needs_redraw { - self.request_redraw(); - } - if self.composer.is_in_paste_burst() { - self.request_redraw_in(ChatComposer::recommended_paste_flush_delay()); - } - input_result - } - } - - /// Handles a Ctrl+C press within the bottom pane. - /// - /// An active modal view is given the first chance to consume the key (typically to dismiss - /// itself). If no view is active, Ctrl+C clears draft composer input. - /// - /// This method may show the quit shortcut hint as a user-visible acknowledgement that Ctrl+C - /// was received, but it does not decide whether the process should exit; `ChatWidget` owns the - /// quit/interrupt state machine and uses the result to decide what happens next. - pub(crate) fn on_ctrl_c(&mut self) -> CancellationEvent { - if let Some(view) = self.view_stack.last_mut() { - let event = view.on_ctrl_c(); - if matches!(event, CancellationEvent::Handled) { - if view.is_complete() { - self.view_stack.pop(); - self.on_active_view_complete(); - } - self.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c'))); - } - event - } else if self.composer_is_empty() { - CancellationEvent::NotHandled - } else { - self.view_stack.pop(); - self.clear_composer_for_ctrl_c(); - self.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c'))); - CancellationEvent::Handled - } - } - - pub fn handle_paste(&mut self, pasted: String) { - if let Some(view) = self.view_stack.last_mut() { - let needs_redraw = view.handle_paste(pasted); - if view.is_complete() { - self.on_active_view_complete(); - } - if needs_redraw { - self.request_redraw(); - } - } else { - let needs_redraw = self.composer.handle_paste(pasted); - if needs_redraw { - self.request_redraw(); - } - } - } - - pub(crate) fn insert_str(&mut self, text: &str) { - self.composer.insert_str(text); - self.request_redraw(); - } - - /// Replace the composer text with `text`. - pub(crate) fn set_composer_text( - &mut self, - text: String, - text_elements: Vec, - local_image_paths: Vec, - ) { - self.composer - .set_text_content(text, text_elements, local_image_paths); - self.request_redraw(); - } - - #[allow(dead_code)] - pub(crate) fn set_composer_input_enabled( - &mut self, - enabled: bool, - placeholder: Option, - ) { - self.composer.set_input_enabled(enabled, placeholder); - self.request_redraw(); - } - - pub(crate) fn clear_composer_for_ctrl_c(&mut self) { - self.composer.clear_for_ctrl_c(); - self.request_redraw(); - } - - /// Get the current composer text (for tests and programmatic checks). - pub(crate) fn composer_text(&self) -> String { - self.composer.current_text() - } - - pub(crate) fn composer_text_elements(&self) -> Vec { - self.composer.text_elements() - } - - pub(crate) fn composer_local_images(&self) -> Vec { - self.composer.local_images() - } - - #[cfg(test)] - pub(crate) fn composer_local_image_paths(&self) -> Vec { - self.composer.local_image_paths() - } - - /// Update the status indicator header (defaults to "Working") and details below it. - /// - /// Passing `None` clears any existing details. No-ops if the status indicator is not active. - pub(crate) fn update_status(&mut self, header: String, details: Option) { - if let Some(status) = self.status.as_mut() { - status.update_header(header); - status.update_details(details); - self.request_redraw(); - } - } - - /// Show the transient "press again to quit" hint for `key`. - /// - /// `ChatWidget` owns the quit shortcut state machine (it decides when quit is - /// allowed), while the bottom pane owns rendering. We also schedule a redraw - /// after [`QUIT_SHORTCUT_TIMEOUT`] so the hint disappears even if the user - /// stops typing and no other events trigger a draw. - pub(crate) fn show_quit_shortcut_hint(&mut self, key: KeyBinding) { - if !DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED { - return; - } - - self.composer - .show_quit_shortcut_hint(key, self.has_input_focus); - let frame_requester = self.frame_requester.clone(); - if let Ok(handle) = tokio::runtime::Handle::try_current() { - handle.spawn(async move { - tokio::time::sleep(QUIT_SHORTCUT_TIMEOUT).await; - frame_requester.schedule_frame(); - }); - } else { - // In tests (and other non-Tokio contexts), fall back to a thread so - // the hint can still expire without requiring an explicit draw. - std::thread::spawn(move || { - std::thread::sleep(QUIT_SHORTCUT_TIMEOUT); - frame_requester.schedule_frame(); - }); - } - self.request_redraw(); - } - - /// Clear the "press again to quit" hint immediately. - pub(crate) fn clear_quit_shortcut_hint(&mut self) { - self.composer.clear_quit_shortcut_hint(self.has_input_focus); - self.request_redraw(); - } - - #[cfg(test)] - pub(crate) fn quit_shortcut_hint_visible(&self) -> bool { - self.composer.quit_shortcut_hint_visible() - } - - #[cfg(test)] - pub(crate) fn status_indicator_visible(&self) -> bool { - self.status.is_some() - } - - pub(crate) fn show_esc_backtrack_hint(&mut self) { - self.esc_backtrack_hint = true; - self.composer.set_esc_backtrack_hint(true); - self.request_redraw(); - } - - pub(crate) fn clear_esc_backtrack_hint(&mut self) { - if self.esc_backtrack_hint { - self.esc_backtrack_hint = false; - self.composer.set_esc_backtrack_hint(false); - self.request_redraw(); - } - } - - // esc_backtrack_hint_visible removed; hints are controlled internally. - - pub fn set_task_running(&mut self, running: bool) { - let was_running = self.is_task_running; - self.is_task_running = running; - self.composer.set_task_running(running); - - if running { - if !was_running { - if self.status.is_none() { - self.status = Some(StatusIndicatorWidget::new( - self.app_event_tx.clone(), - self.frame_requester.clone(), - self.animations_enabled, - )); - } - if let Some(status) = self.status.as_mut() { - status.set_interrupt_hint_visible(true); - } - self.request_redraw(); - } - } else { - // Hide the status indicator when a task completes, but keep other modal views. - self.hide_status_indicator(); - } - } - - /// Hide the status indicator while leaving task-running state untouched. - pub(crate) fn hide_status_indicator(&mut self) { - if self.status.take().is_some() { - self.request_redraw(); - } - } - - pub(crate) fn ensure_status_indicator(&mut self) { - if self.status.is_none() { - self.status = Some(StatusIndicatorWidget::new( - self.app_event_tx.clone(), - self.frame_requester.clone(), - self.animations_enabled, - )); - self.request_redraw(); - } - } - - pub(crate) fn set_interrupt_hint_visible(&mut self, visible: bool) { - if let Some(status) = self.status.as_mut() { - status.set_interrupt_hint_visible(visible); - self.request_redraw(); - } - } - - pub(crate) fn set_context_window(&mut self, percent: Option, used_tokens: Option) { - if self.context_window_percent == percent && self.context_window_used_tokens == used_tokens - { - return; - } - - self.context_window_percent = percent; - self.context_window_used_tokens = used_tokens; - self.composer - .set_context_window(percent, self.context_window_used_tokens); - self.request_redraw(); - } - - pub(crate) fn set_transcript_ui_state( - &mut self, - scrolled: bool, - selection_active: bool, - scroll_position: Option<(usize, usize)>, - copy_selection_key: crate::key_hint::KeyBinding, - copy_feedback: Option, - ) { - let updated = self.composer.set_transcript_ui_state( - scrolled, - selection_active, - scroll_position, - copy_selection_key, - copy_feedback, - ); - if updated { - self.request_redraw(); - } - } - - /// Show a generic list selection view with the provided items. - pub(crate) fn show_selection_view(&mut self, params: list_selection_view::SelectionViewParams) { - let view = list_selection_view::ListSelectionView::new(params, self.app_event_tx.clone()); - self.push_view(Box::new(view)); - } - - /// Update the queued messages preview shown above the composer. - pub(crate) fn set_queued_user_messages(&mut self, queued: Vec) { - self.queued_user_messages.messages = queued; - self.request_redraw(); - } - - /// Update custom prompts available for the slash popup. - pub(crate) fn set_custom_prompts(&mut self, prompts: Vec) { - self.composer.set_custom_prompts(prompts); - self.request_redraw(); - } - - pub(crate) fn flash_footer_hint(&mut self, line: Line<'static>, duration: Duration) { - self.composer.show_footer_flash(line, duration); - let frame_requester = self.frame_requester.clone(); - if let Ok(handle) = tokio::runtime::Handle::try_current() { - handle.spawn(async move { - tokio::time::sleep(duration).await; - frame_requester.schedule_frame(); - }); - } else { - std::thread::spawn(move || { - std::thread::sleep(duration); - frame_requester.schedule_frame(); - }); - } - self.request_redraw(); - } - - pub(crate) fn composer_is_empty(&self) -> bool { - self.composer.is_empty() - } - - pub(crate) fn is_task_running(&self) -> bool { - self.is_task_running - } - - /// Return true when the pane is in the regular composer state without any - /// overlays or popups and not running a task. This is the safe context to - /// use Esc-Esc for backtracking from the main view. - pub(crate) fn is_normal_backtrack_mode(&self) -> bool { - !self.is_task_running && self.view_stack.is_empty() && !self.composer.popup_active() - } - - /// Return true when no popups or modal views are active, regardless of task state. - pub(crate) fn can_launch_external_editor(&self) -> bool { - self.view_stack.is_empty() && !self.composer.popup_active() - } - - /// Returns true when the bottom pane has no active modal view and no active composer popup. - /// - /// This is the UI-level definition of "no modal/popup is active" for key routing decisions. - /// It intentionally does not include task state, since some actions are safe while a task is - /// running and some are not. - pub(crate) fn no_modal_or_popup_active(&self) -> bool { - self.can_launch_external_editor() - } - - pub(crate) fn show_view(&mut self, view: Box) { - self.push_view(view); - } - - /// Called when the agent requests user approval. - pub fn push_approval_request(&mut self, request: ApprovalRequest, features: &Features) { - let request = if let Some(view) = self.view_stack.last_mut() { - match view.try_consume_approval_request(request) { - Some(request) => request, - None => { - self.request_redraw(); - return; - } - } - } else { - request - }; - - // Otherwise create a new approval modal overlay. - let modal = ApprovalOverlay::new(request, self.app_event_tx.clone(), features.clone()); - self.pause_status_timer_for_modal(); - self.push_view(Box::new(modal)); - } - - fn on_active_view_complete(&mut self) { - self.resume_status_timer_after_modal(); - } - - fn pause_status_timer_for_modal(&mut self) { - if let Some(status) = self.status.as_mut() { - status.pause_timer(); - } - } - - fn resume_status_timer_after_modal(&mut self) { - if let Some(status) = self.status.as_mut() { - status.resume_timer(); - } - } - - /// Height (terminal rows) required by the current bottom pane. - pub(crate) fn request_redraw(&self) { - self.frame_requester.schedule_frame(); - } - - pub(crate) fn request_redraw_in(&self, dur: Duration) { - self.frame_requester.schedule_frame_in(dur); - } - - // --- History helpers --- - - pub(crate) fn set_history_metadata(&mut self, log_id: u64, entry_count: usize) { - self.composer.set_history_metadata(log_id, entry_count); - } - - pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool { - self.composer.flush_paste_burst_if_due() - } - - pub(crate) fn is_in_paste_burst(&self) -> bool { - self.composer.is_in_paste_burst() - } - - pub(crate) fn on_history_entry_response( - &mut self, - log_id: u64, - offset: usize, - entry: Option, - ) { - let updated = self - .composer - .on_history_entry_response(log_id, offset, entry); - - if updated { - self.request_redraw(); - } - } - - pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec) { - self.composer.on_file_search_result(query, matches); - self.request_redraw(); - } - - pub(crate) fn attach_image(&mut self, path: PathBuf) { - if self.view_stack.is_empty() { - self.composer.attach_image(path); - self.request_redraw(); - } - } - - #[cfg(test)] - pub(crate) fn take_recent_submission_images(&mut self) -> Vec { - self.composer.take_recent_submission_images() - } - - pub(crate) fn take_recent_submission_images_with_placeholders( - &mut self, - ) -> Vec { - self.composer - .take_recent_submission_images_with_placeholders() - } - - fn as_renderable(&'_ self) -> RenderableItem<'_> { - if let Some(view) = self.active_view() { - RenderableItem::Borrowed(view) - } else { - let mut flex = FlexRenderable::new(); - if let Some(status) = &self.status { - flex.push(0, RenderableItem::Borrowed(status)); - } - let has_queued_messages = !self.queued_user_messages.messages.is_empty(); - let has_status = self.status.is_some(); - if has_queued_messages && has_status { - flex.push(0, RenderableItem::Owned("".into())); - } - flex.push(1, RenderableItem::Borrowed(&self.queued_user_messages)); - if !has_queued_messages && has_status { - flex.push(0, RenderableItem::Owned("".into())); - } - let mut flex2 = FlexRenderable::new(); - flex2.push(1, RenderableItem::Owned(flex.into())); - flex2.push(0, RenderableItem::Borrowed(&self.composer)); - RenderableItem::Owned(Box::new(flex2)) - } - } -} - -impl Renderable for BottomPane { - fn render(&self, area: Rect, buf: &mut Buffer) { - self.as_renderable().render(area, buf); - } - fn desired_height(&self, width: u16) -> u16 { - self.as_renderable().desired_height(width) - } - fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { - self.as_renderable().cursor_pos(area) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::app_event::AppEvent; - use insta::assert_snapshot; - use ratatui::buffer::Buffer; - use ratatui::layout::Rect; - use tokio::sync::mpsc::unbounded_channel; - - fn snapshot_buffer(buf: &Buffer) -> String { - let mut lines = Vec::new(); - for y in 0..buf.area().height { - let mut row = String::new(); - for x in 0..buf.area().width { - row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); - } - lines.push(row); - } - lines.join("\n") - } - - fn render_snapshot(pane: &BottomPane, area: Rect) -> String { - let mut buf = Buffer::empty(area); - pane.render(area, &mut buf); - snapshot_buffer(&buf) - } - - fn exec_request() -> ApprovalRequest { - ApprovalRequest::Exec { - id: "1".to_string(), - command: vec!["echo".into(), "ok".into()], - reason: None, - proposed_execpolicy_amendment: None, - } - } - - #[test] - fn ctrl_c_on_modal_consumes_without_showing_quit_hint() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let features = Features::with_defaults(); - let mut pane = BottomPane::new(BottomPaneParams { - app_event_tx: tx, - frame_requester: FrameRequester::test_dummy(), - has_input_focus: true, - enhanced_keys_supported: false, - placeholder_text: "Ask Codex to do anything".to_string(), - disable_paste_burst: false, - animations_enabled: true, - skills: Some(Vec::new()), - }); - pane.push_approval_request(exec_request(), &features); - assert_eq!(CancellationEvent::Handled, pane.on_ctrl_c()); - assert!(!pane.quit_shortcut_hint_visible()); - assert_eq!(CancellationEvent::NotHandled, pane.on_ctrl_c()); - } - - // live ring removed; related tests deleted. - - #[test] - fn overlay_not_shown_above_approval_modal() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let features = Features::with_defaults(); - let mut pane = BottomPane::new(BottomPaneParams { - app_event_tx: tx, - frame_requester: FrameRequester::test_dummy(), - has_input_focus: true, - enhanced_keys_supported: false, - placeholder_text: "Ask Codex to do anything".to_string(), - disable_paste_burst: false, - animations_enabled: true, - skills: Some(Vec::new()), - }); - - // Create an approval modal (active view). - pane.push_approval_request(exec_request(), &features); - - // Render and verify the top row does not include an overlay. - let area = Rect::new(0, 0, 60, 6); - let mut buf = Buffer::empty(area); - pane.render(area, &mut buf); - - let mut r0 = String::new(); - for x in 0..area.width { - r0.push(buf[(x, 0)].symbol().chars().next().unwrap_or(' ')); - } - assert!( - !r0.contains("Working"), - "overlay should not render above modal" - ); - } - - #[test] - fn composer_shown_after_denied_while_task_running() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let features = Features::with_defaults(); - let mut pane = BottomPane::new(BottomPaneParams { - app_event_tx: tx, - frame_requester: FrameRequester::test_dummy(), - has_input_focus: true, - enhanced_keys_supported: false, - placeholder_text: "Ask Codex to do anything".to_string(), - disable_paste_burst: false, - animations_enabled: true, - skills: Some(Vec::new()), - }); - - // Start a running task so the status indicator is active above the composer. - pane.set_task_running(true); - - // Push an approval modal (e.g., command approval) which should hide the status view. - pane.push_approval_request(exec_request(), &features); - - // Simulate pressing 'n' (No) on the modal. - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - pane.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); - - // After denial, since the task is still running, the status indicator should be - // visible above the composer. The modal should be gone. - assert!( - pane.view_stack.is_empty(), - "no active modal view after denial" - ); - - // Render and ensure the top row includes the Working header and a composer line below. - // Give the animation thread a moment to tick. - std::thread::sleep(Duration::from_millis(120)); - let area = Rect::new(0, 0, 40, 6); - let mut buf = Buffer::empty(area); - pane.render(area, &mut buf); - let mut row0 = String::new(); - for x in 0..area.width { - row0.push(buf[(x, 0)].symbol().chars().next().unwrap_or(' ')); - } - assert!( - row0.contains("Working"), - "expected Working header after denial on row 0: {row0:?}" - ); - - // Composer placeholder should be visible somewhere below. - let mut found_composer = false; - for y in 1..area.height { - let mut row = String::new(); - for x in 0..area.width { - row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); - } - if row.contains("Ask Codex") { - found_composer = true; - break; - } - } - assert!( - found_composer, - "expected composer visible under status line" - ); - } - - #[test] - fn status_indicator_visible_during_command_execution() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let mut pane = BottomPane::new(BottomPaneParams { - app_event_tx: tx, - frame_requester: FrameRequester::test_dummy(), - has_input_focus: true, - enhanced_keys_supported: false, - placeholder_text: "Ask Codex to do anything".to_string(), - disable_paste_burst: false, - animations_enabled: true, - skills: Some(Vec::new()), - }); - - // Begin a task: show initial status. - pane.set_task_running(true); - - // Use a height that allows the status line to be visible above the composer. - let area = Rect::new(0, 0, 40, 6); - let mut buf = Buffer::empty(area); - pane.render(area, &mut buf); - - let bufs = snapshot_buffer(&buf); - assert!(bufs.contains("• Working"), "expected Working header"); - } - - #[test] - fn status_and_composer_fill_height_without_bottom_padding() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let mut pane = BottomPane::new(BottomPaneParams { - app_event_tx: tx, - frame_requester: FrameRequester::test_dummy(), - has_input_focus: true, - enhanced_keys_supported: false, - placeholder_text: "Ask Codex to do anything".to_string(), - disable_paste_burst: false, - animations_enabled: true, - skills: Some(Vec::new()), - }); - - // Activate spinner (status view replaces composer) with no live ring. - pane.set_task_running(true); - - // Use height == desired_height; expect spacer + status + composer rows without trailing padding. - let height = pane.desired_height(30); - assert!( - height >= 3, - "expected at least 3 rows to render spacer, status, and composer; got {height}" - ); - let area = Rect::new(0, 0, 30, height); - assert_snapshot!( - "status_and_composer_fill_height_without_bottom_padding", - render_snapshot(&pane, area) - ); - } - - #[test] - fn status_only_snapshot() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let mut pane = BottomPane::new(BottomPaneParams { - app_event_tx: tx, - frame_requester: FrameRequester::test_dummy(), - has_input_focus: true, - enhanced_keys_supported: false, - placeholder_text: "Ask Codex to do anything".to_string(), - disable_paste_burst: false, - animations_enabled: true, - skills: Some(Vec::new()), - }); - - pane.set_task_running(true); - - let width = 48; - let height = pane.desired_height(width); - let area = Rect::new(0, 0, width, height); - assert_snapshot!("status_only_snapshot", render_snapshot(&pane, area)); - } - - #[test] - fn status_with_details_and_queued_messages_snapshot() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let mut pane = BottomPane::new(BottomPaneParams { - app_event_tx: tx, - frame_requester: FrameRequester::test_dummy(), - has_input_focus: true, - enhanced_keys_supported: false, - placeholder_text: "Ask Codex to do anything".to_string(), - disable_paste_burst: false, - animations_enabled: true, - skills: Some(Vec::new()), - }); - - pane.set_task_running(true); - pane.update_status( - "Working".to_string(), - Some("First detail line\nSecond detail line".to_string()), - ); - pane.set_queued_user_messages(vec!["Queued follow-up question".to_string()]); - - let width = 48; - let height = pane.desired_height(width); - let area = Rect::new(0, 0, width, height); - assert_snapshot!( - "status_with_details_and_queued_messages_snapshot", - render_snapshot(&pane, area) - ); - } - - #[test] - fn queued_messages_visible_when_status_hidden_snapshot() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let mut pane = BottomPane::new(BottomPaneParams { - app_event_tx: tx, - frame_requester: FrameRequester::test_dummy(), - has_input_focus: true, - enhanced_keys_supported: false, - placeholder_text: "Ask Codex to do anything".to_string(), - disable_paste_burst: false, - animations_enabled: true, - skills: Some(Vec::new()), - }); - - pane.set_task_running(true); - pane.set_queued_user_messages(vec!["Queued follow-up question".to_string()]); - pane.hide_status_indicator(); - - let width = 48; - let height = pane.desired_height(width); - let area = Rect::new(0, 0, width, height); - assert_snapshot!( - "queued_messages_visible_when_status_hidden_snapshot", - render_snapshot(&pane, area) - ); - } - - #[test] - fn status_and_queued_messages_snapshot() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let mut pane = BottomPane::new(BottomPaneParams { - app_event_tx: tx, - frame_requester: FrameRequester::test_dummy(), - has_input_focus: true, - enhanced_keys_supported: false, - placeholder_text: "Ask Codex to do anything".to_string(), - disable_paste_burst: false, - animations_enabled: true, - skills: Some(Vec::new()), - }); - - pane.set_task_running(true); - pane.set_queued_user_messages(vec!["Queued follow-up question".to_string()]); - - let width = 48; - let height = pane.desired_height(width); - let area = Rect::new(0, 0, width, height); - assert_snapshot!( - "status_and_queued_messages_snapshot", - render_snapshot(&pane, area) - ); - } -} diff --git a/codex-rs/tui2/src/bottom_pane/paste_burst.rs b/codex-rs/tui2/src/bottom_pane/paste_burst.rs deleted file mode 100644 index 238c00d600b..00000000000 --- a/codex-rs/tui2/src/bottom_pane/paste_burst.rs +++ /dev/null @@ -1,564 +0,0 @@ -//! Paste-burst detection for terminals without bracketed paste. -//! -//! On some platforms (notably Windows), pastes often arrive as a rapid stream of -//! `KeyCode::Char` and `KeyCode::Enter` key events rather than as a single "paste" event. -//! In that mode, the composer needs to: -//! -//! - Prevent transient UI side effects (e.g. toggles bound to `?`) from triggering on pasted text. -//! - Ensure Enter is treated as a newline *inside the paste*, not as "submit the message". -//! - Avoid flicker caused by inserting a typed prefix and then immediately reclassifying it as -//! paste once enough chars have arrived. -//! -//! This module provides the `PasteBurst` state machine. `ChatComposer` feeds it only "plain" -//! character events (no Ctrl/Alt) and uses its decisions to either: -//! -//! - briefly hold a first ASCII char (flicker suppression), -//! - buffer a burst as a single pasted string, or -//! - let input flow through as normal typing. -//! -//! For the higher-level view of how `PasteBurst` integrates with `ChatComposer`, see -//! `docs/tui-chat-composer.md`. -//! -//! # Call Pattern -//! -//! `PasteBurst` is a pure state machine: it never mutates the textarea directly. The caller feeds -//! it events and then applies the chosen action: -//! -//! - For each plain `KeyCode::Char`, call [`PasteBurst::on_plain_char`] (ASCII) or -//! [`PasteBurst::on_plain_char_no_hold`] (non-ASCII/IME). -//! - If the decision indicates buffering, the caller appends to `PasteBurst.buffer` via -//! [`PasteBurst::append_char_to_buffer`]. -//! - On a UI tick, call [`PasteBurst::flush_if_due`]. If it returns [`FlushResult::Typed`], insert -//! that char as normal typing. If it returns [`FlushResult::Paste`], treat the returned string as -//! an explicit paste. -//! - Before applying non-char input (arrow keys, Ctrl/Alt modifiers, etc.), use -//! [`PasteBurst::flush_before_modified_input`] to avoid leaving buffered text "stuck", and then -//! [`PasteBurst::clear_window_after_non_char`] so subsequent typing does not get grouped into a -//! previous burst. -//! -//! # State Variables -//! -//! This state machine is encoded in a few fields with slightly different meanings: -//! -//! - `active`: true while we are still *actively* accepting characters into the current burst. -//! - `buffer`: accumulated burst text that will eventually flush as a single `Paste(String)`. -//! A non-empty buffer is treated as "in burst context" even if `active` has been cleared. -//! - `pending_first_char`: a single held ASCII char used for flicker suppression. The caller must -//! not render this char until it either becomes part of a burst (`BeginBufferFromPending`) or -//! flushes as a normal typed char (`FlushResult::Typed`). -//! - `last_plain_char_time`/`consecutive_plain_char_burst`: the timing/count heuristic for -//! "paste-like" streams. -//! - `burst_window_until`: the Enter suppression window ("Enter inserts newline") that outlives the -//! buffer itself. -//! -//! # Timing Model -//! -//! There are two timeouts: -//! -//! - `PASTE_BURST_CHAR_INTERVAL`: maximum delay between consecutive "plain" chars for them to be -//! considered part of a single burst. It also bounds how long `pending_first_char` is held. -//! - `PASTE_BURST_ACTIVE_IDLE_TIMEOUT`: once buffering is active, how long to wait after the last -//! char before flushing the accumulated buffer as a paste. -//! -//! `flush_if_due()` intentionally uses `>` (not `>=`) when comparing elapsed time, so tests and UI -//! ticks should cross the threshold by at least 1ms (see `recommended_flush_delay()`). -//! -//! # Retro Capture Details -//! -//! Retro-capture exists to handle the case where we initially inserted characters as "normal -//! typing", but later decide that the stream is paste-like. When that happens, we retroactively -//! remove a prefix of already-inserted text from the textarea and move it into the burst buffer so -//! the eventual `handle_paste(...)` sees a contiguous pasted string. -//! -//! Retro-capture mostly matters on paths that do *not* hold the first character (non-ASCII/IME -//! input, and retro-grab scenarios). The ASCII path usually prefers -//! `RetainFirstChar -> BeginBufferFromPending`, which avoids needing retro-capture at all. -//! -//! Retro-capture is expressed in terms of characters, not bytes: -//! -//! - `CharDecision::BeginBuffer { retro_chars }` uses `retro_chars` as a character count. -//! - `decide_begin_buffer(now, before_cursor, retro_chars)` turns that into a UTF-8 byte range by -//! calling `retro_start_index()`. -//! - `RetroGrab.start_byte` is a byte index into the `before_cursor` slice; callers must clamp the -//! cursor to a char boundary before slicing so `start_byte..cursor` is always valid UTF-8. -//! -//! # Clearing vs Flushing -//! -//! There are two ways callers end burst handling, and they are not interchangeable: -//! -//! - `flush_before_modified_input()` returns the buffered text (and/or a pending first ASCII char) -//! so the caller can apply it through the normal paste path before handling an unrelated input. -//! - `clear_window_after_non_char()` clears the *classification window* so subsequent typing does -//! not get grouped into the previous burst. It assumes the caller has already flushed any buffer -//! because it clears `last_plain_char_time`, which means `flush_if_due()` will not flush a -//! non-empty buffer until another plain char updates the timestamp. -//! -//! # States (Conceptually) -//! -//! - **Idle**: no buffered text, no pending char. -//! - **Pending first char**: `pending_first_char` holds one ASCII char for up to -//! `PASTE_BURST_CHAR_INTERVAL` while we wait to see if a burst follows. -//! - **Active buffer**: `active`/`buffer` holds paste-like content until it times out and flushes. -//! - **Enter suppress window**: `burst_window_until` keeps Enter treated as newline briefly after -//! burst activity so multiline pastes stay grouped. -//! -//! # ASCII vs Non-ASCII -//! -//! - [`PasteBurst::on_plain_char`] may return [`CharDecision::RetainFirstChar`] to hold the first -//! ASCII char and avoid flicker. -//! - [`PasteBurst::on_plain_char_no_hold`] never holds (used for IME/non-ASCII paths), since -//! holding a non-ASCII character can feel like dropped input. -//! -//! # Contract With `ChatComposer` -//! -//! `PasteBurst` does not mutate the UI text buffer on its own. The caller (`ChatComposer`) must -//! interpret decisions and apply the corresponding UI edits: -//! -//! - For each plain ASCII `KeyCode::Char`, call [`PasteBurst::on_plain_char`]. -//! - [`CharDecision::RetainFirstChar`]: do **not** insert the char into the textarea yet. -//! - [`CharDecision::BeginBufferFromPending`]: call [`PasteBurst::append_char_to_buffer`] for the -//! current char (the previously-held char is already in the burst buffer). -//! - [`CharDecision::BeginBuffer { retro_chars }`]: consider retro-capturing the already-inserted -//! prefix by calling [`PasteBurst::decide_begin_buffer`]. If it returns `Some`, remove the -//! returned `start_byte..cursor` range from the textarea and then call -//! [`PasteBurst::append_char_to_buffer`] for the current char. If it returns `None`, fall back -//! to normal insertion. -//! - [`CharDecision::BufferAppend`]: call [`PasteBurst::append_char_to_buffer`]. -//! -//! - For each plain non-ASCII `KeyCode::Char`, call [`PasteBurst::on_plain_char_no_hold`] and then: -//! - If it returns `Some(CharDecision::BufferAppend)`, call -//! [`PasteBurst::append_char_to_buffer`]. -//! - If it returns `Some(CharDecision::BeginBuffer { retro_chars })`, call -//! [`PasteBurst::decide_begin_buffer`] as above (and if buffering starts, remove the grabbed -//! prefix from the textarea and then append the current char to the buffer). -//! - If it returns `None`, insert normally. -//! -//! - Before applying non-char input (or any input that should not join a burst), call -//! [`PasteBurst::flush_before_modified_input`] and pass the returned string (if any) through the -//! normal paste path. -//! -//! - Periodically (e.g. on a UI tick), call [`PasteBurst::flush_if_due`]. -//! - [`FlushResult::Typed`]: insert that single char as normal typing. -//! - [`FlushResult::Paste`]: treat the returned string as an explicit paste. -//! -//! - When a non-plain key is pressed (Ctrl/Alt-modified input, arrows, etc.), callers should use -//! [`PasteBurst::clear_window_after_non_char`] to prevent the next keystroke from being -//! incorrectly grouped into a previous burst. - -use std::time::Duration; -use std::time::Instant; - -// Heuristic thresholds for detecting paste-like input bursts. -// Detect quickly to avoid showing typed prefix before paste is recognized -const PASTE_BURST_MIN_CHARS: u16 = 3; -const PASTE_BURST_CHAR_INTERVAL: Duration = Duration::from_millis(8); -const PASTE_ENTER_SUPPRESS_WINDOW: Duration = Duration::from_millis(120); -// Slower paste burts have been observed in windows environments, but ideally -// we want to keep this low -#[cfg(not(windows))] -const PASTE_BURST_ACTIVE_IDLE_TIMEOUT: Duration = Duration::from_millis(8); -#[cfg(windows)] -const PASTE_BURST_ACTIVE_IDLE_TIMEOUT: Duration = Duration::from_millis(60); - -#[derive(Default)] -pub(crate) struct PasteBurst { - last_plain_char_time: Option, - consecutive_plain_char_burst: u16, - burst_window_until: Option, - buffer: String, - active: bool, - // Hold first fast char briefly to avoid rendering flicker - pending_first_char: Option<(char, Instant)>, -} - -pub(crate) enum CharDecision { - /// Start buffering and retroactively capture some already-inserted chars. - BeginBuffer { retro_chars: u16 }, - /// We are currently buffering; append the current char into the buffer. - BufferAppend, - /// Do not insert/render this char yet; temporarily save the first fast - /// char while we wait to see if a paste-like burst follows. - RetainFirstChar, - /// Begin buffering using the previously saved first char (no retro grab needed). - BeginBufferFromPending, -} - -pub(crate) struct RetroGrab { - pub start_byte: usize, - pub grabbed: String, -} - -pub(crate) enum FlushResult { - Paste(String), - Typed(char), - None, -} - -impl PasteBurst { - /// Recommended delay to wait between simulated keypresses (or before - /// scheduling a UI tick) so that a pending fast keystroke is flushed - /// out of the burst detector as normal typed input. - /// - /// Primarily used by tests and by the TUI to reliably cross the - /// paste-burst timing threshold. - pub fn recommended_flush_delay() -> Duration { - PASTE_BURST_CHAR_INTERVAL + Duration::from_millis(1) - } - - #[cfg(test)] - pub(crate) fn recommended_active_flush_delay() -> Duration { - PASTE_BURST_ACTIVE_IDLE_TIMEOUT + Duration::from_millis(1) - } - - /// Entry point: decide how to treat a plain char with current timing. - pub fn on_plain_char(&mut self, ch: char, now: Instant) -> CharDecision { - self.note_plain_char(now); - - if self.active { - self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW); - return CharDecision::BufferAppend; - } - - // If we already held a first char and receive a second fast char, - // start buffering without retro-grabbing (we never rendered the first). - if let Some((held, held_at)) = self.pending_first_char - && now.duration_since(held_at) <= PASTE_BURST_CHAR_INTERVAL - { - self.active = true; - // take() to clear pending; we already captured the held char above - let _ = self.pending_first_char.take(); - self.buffer.push(held); - self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW); - return CharDecision::BeginBufferFromPending; - } - - if self.consecutive_plain_char_burst >= PASTE_BURST_MIN_CHARS { - return CharDecision::BeginBuffer { - retro_chars: self.consecutive_plain_char_burst.saturating_sub(1), - }; - } - - // Save the first fast char very briefly to see if a burst follows. - self.pending_first_char = Some((ch, now)); - CharDecision::RetainFirstChar - } - - /// Like on_plain_char(), but never holds the first char. - /// - /// Used for non-ASCII input paths (e.g., IMEs) where holding a character can - /// feel like dropped input, while still allowing burst-based paste detection. - /// - /// Note: This method will only ever return BufferAppend or BeginBuffer. - pub fn on_plain_char_no_hold(&mut self, now: Instant) -> Option { - self.note_plain_char(now); - - if self.active { - self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW); - return Some(CharDecision::BufferAppend); - } - - if self.consecutive_plain_char_burst >= PASTE_BURST_MIN_CHARS { - return Some(CharDecision::BeginBuffer { - retro_chars: self.consecutive_plain_char_burst.saturating_sub(1), - }); - } - - None - } - - fn note_plain_char(&mut self, now: Instant) { - match self.last_plain_char_time { - Some(prev) if now.duration_since(prev) <= PASTE_BURST_CHAR_INTERVAL => { - self.consecutive_plain_char_burst = - self.consecutive_plain_char_burst.saturating_add(1) - } - _ => self.consecutive_plain_char_burst = 1, - } - self.last_plain_char_time = Some(now); - } - - /// Flushes any buffered burst if the inter-key timeout has elapsed. - /// - /// Returns: - /// - /// - [`FlushResult::Paste`] when a paste burst was active and buffered text is emitted as one - /// pasted string. - /// - [`FlushResult::Typed`] when a single fast first ASCII char was being held (flicker - /// suppression) and no burst followed before the timeout elapsed. - /// - [`FlushResult::None`] when the timeout has not elapsed, or there is nothing to flush. - pub fn flush_if_due(&mut self, now: Instant) -> FlushResult { - let timeout = if self.is_active_internal() { - PASTE_BURST_ACTIVE_IDLE_TIMEOUT - } else { - PASTE_BURST_CHAR_INTERVAL - }; - let timed_out = self - .last_plain_char_time - .is_some_and(|t| now.duration_since(t) > timeout); - if timed_out && self.is_active_internal() { - self.active = false; - let out = std::mem::take(&mut self.buffer); - FlushResult::Paste(out) - } else if timed_out { - // If we were saving a single fast char and no burst followed, - // flush it as normal typed input. - if let Some((ch, _at)) = self.pending_first_char.take() { - FlushResult::Typed(ch) - } else { - FlushResult::None - } - } else { - FlushResult::None - } - } - - /// While bursting: accumulate a newline into the buffer instead of - /// submitting the textarea. - /// - /// Returns true if a newline was appended (we are in a burst context), - /// false otherwise. - pub fn append_newline_if_active(&mut self, now: Instant) -> bool { - if self.is_active() { - self.buffer.push('\n'); - self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW); - true - } else { - false - } - } - - /// Decide if Enter should insert a newline (burst context) vs submit. - pub fn newline_should_insert_instead_of_submit(&self, now: Instant) -> bool { - let in_burst_window = self.burst_window_until.is_some_and(|until| now <= until); - self.is_active() || in_burst_window - } - - /// Keep the burst window alive. - pub fn extend_window(&mut self, now: Instant) { - self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW); - } - - /// Begin buffering with retroactively grabbed text. - pub fn begin_with_retro_grabbed(&mut self, grabbed: String, now: Instant) { - if !grabbed.is_empty() { - self.buffer.push_str(&grabbed); - } - self.active = true; - self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW); - } - - /// Append a char into the burst buffer. - pub fn append_char_to_buffer(&mut self, ch: char, now: Instant) { - self.buffer.push(ch); - self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW); - } - - /// Try to append a char into the burst buffer only if a burst is already active. - /// - /// Returns true when the char was captured into the existing burst, false otherwise. - pub fn try_append_char_if_active(&mut self, ch: char, now: Instant) -> bool { - if self.active || !self.buffer.is_empty() { - self.append_char_to_buffer(ch, now); - true - } else { - false - } - } - - /// Decide whether to begin buffering by retroactively capturing recent - /// chars from the slice before the cursor. - /// - /// Heuristic: if the retro-grabbed slice contains any whitespace or is - /// sufficiently long (>= 16 characters), treat it as paste-like to avoid - /// rendering the typed prefix momentarily before the paste is recognized. - /// This favors responsiveness and prevents flicker for typical pastes - /// (URLs, file paths, multiline text) while not triggering on short words. - /// - /// Returns Some(RetroGrab) with the start byte and grabbed text when we - /// decide to buffer retroactively; otherwise None. - pub fn decide_begin_buffer( - &mut self, - now: Instant, - before: &str, - retro_chars: usize, - ) -> Option { - let start_byte = retro_start_index(before, retro_chars); - let grabbed = before[start_byte..].to_string(); - let looks_pastey = - grabbed.chars().any(char::is_whitespace) || grabbed.chars().count() >= 16; - if looks_pastey { - // Note: caller is responsible for removing this slice from UI text. - self.begin_with_retro_grabbed(grabbed.clone(), now); - Some(RetroGrab { - start_byte, - grabbed, - }) - } else { - None - } - } - - /// Before applying modified/non-char input: flush buffered burst immediately. - pub fn flush_before_modified_input(&mut self) -> Option { - if !self.is_active() { - return None; - } - self.active = false; - let mut out = std::mem::take(&mut self.buffer); - if let Some((ch, _at)) = self.pending_first_char.take() { - out.push(ch); - } - Some(out) - } - - /// Clear only the timing window and any pending first-char. - /// - /// Does not emit or clear the buffered text itself; callers should have - /// already flushed (if needed) via one of the flush methods above. - pub fn clear_window_after_non_char(&mut self) { - self.consecutive_plain_char_burst = 0; - self.last_plain_char_time = None; - self.burst_window_until = None; - self.active = false; - self.pending_first_char = None; - } - - /// Returns true if we are in any paste-burst related transient state - /// (actively buffering, have a non-empty buffer, or have saved the first - /// fast char while waiting for a potential burst). - pub fn is_active(&self) -> bool { - self.is_active_internal() || self.pending_first_char.is_some() - } - - fn is_active_internal(&self) -> bool { - self.active || !self.buffer.is_empty() - } - - pub fn clear_after_explicit_paste(&mut self) { - self.last_plain_char_time = None; - self.consecutive_plain_char_burst = 0; - self.burst_window_until = None; - self.active = false; - self.buffer.clear(); - self.pending_first_char = None; - } -} - -pub(crate) fn retro_start_index(before: &str, retro_chars: usize) -> usize { - if retro_chars == 0 { - return before.len(); - } - before - .char_indices() - .rev() - .nth(retro_chars.saturating_sub(1)) - .map(|(idx, _)| idx) - .unwrap_or(0) -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - - /// Behavior: for ASCII input we "hold" the first fast char briefly. If no burst follows, - /// that held char should eventually flush as normal typed input (not as a paste). - #[test] - fn ascii_first_char_is_held_then_flushes_as_typed() { - let mut burst = PasteBurst::default(); - let t0 = Instant::now(); - assert!(matches!( - burst.on_plain_char('a', t0), - CharDecision::RetainFirstChar - )); - - let t1 = t0 + PasteBurst::recommended_flush_delay() + Duration::from_millis(1); - assert!(matches!(burst.flush_if_due(t1), FlushResult::Typed('a'))); - assert!(!burst.is_active()); - } - - /// Behavior: if two ASCII chars arrive quickly, we should start buffering without ever - /// rendering the first one, then flush the whole buffered payload as a paste. - #[test] - fn ascii_two_fast_chars_start_buffer_from_pending_and_flush_as_paste() { - let mut burst = PasteBurst::default(); - let t0 = Instant::now(); - assert!(matches!( - burst.on_plain_char('a', t0), - CharDecision::RetainFirstChar - )); - - let t1 = t0 + Duration::from_millis(1); - assert!(matches!( - burst.on_plain_char('b', t1), - CharDecision::BeginBufferFromPending - )); - burst.append_char_to_buffer('b', t1); - - let t2 = t1 + PasteBurst::recommended_active_flush_delay() + Duration::from_millis(1); - assert!(matches!( - burst.flush_if_due(t2), - FlushResult::Paste(ref s) if s == "ab" - )); - } - - /// Behavior: when non-char input is about to be applied, we flush any transient burst state - /// immediately (including a single pending ASCII char) so state doesn't leak across inputs. - #[test] - fn flush_before_modified_input_includes_pending_first_char() { - let mut burst = PasteBurst::default(); - let t0 = Instant::now(); - assert!(matches!( - burst.on_plain_char('a', t0), - CharDecision::RetainFirstChar - )); - - assert_eq!(burst.flush_before_modified_input(), Some("a".to_string())); - assert!(!burst.is_active()); - } - - /// Behavior: retro-grab buffering is only enabled when the already-inserted prefix looks - /// paste-like (whitespace or "long enough") so short IME bursts don't get misclassified. - #[test] - fn decide_begin_buffer_only_triggers_for_pastey_prefixes() { - let mut burst = PasteBurst::default(); - let now = Instant::now(); - - assert!(burst.decide_begin_buffer(now, "ab", 2).is_none()); - assert!(!burst.is_active()); - - let grab = burst - .decide_begin_buffer(now, "a b", 2) - .expect("whitespace should be considered paste-like"); - assert_eq!(grab.start_byte, 1); - assert_eq!(grab.grabbed, " b"); - assert!(burst.is_active()); - } - - /// Behavior: after a paste-like burst, we keep an "enter suppression window" alive briefly so - /// a slightly-late Enter still inserts a newline instead of submitting. - #[test] - fn newline_suppression_window_outlives_buffer_flush() { - let mut burst = PasteBurst::default(); - let t0 = Instant::now(); - assert!(matches!( - burst.on_plain_char('a', t0), - CharDecision::RetainFirstChar - )); - - let t1 = t0 + Duration::from_millis(1); - assert!(matches!( - burst.on_plain_char('b', t1), - CharDecision::BeginBufferFromPending - )); - burst.append_char_to_buffer('b', t1); - - let t2 = t1 + PasteBurst::recommended_active_flush_delay() + Duration::from_millis(1); - assert!(matches!(burst.flush_if_due(t2), FlushResult::Paste(ref s) if s == "ab")); - assert!(!burst.is_active()); - - assert!(burst.newline_should_insert_instead_of_submit(t2)); - let t3 = t1 + PASTE_ENTER_SUPPRESS_WINDOW + Duration::from_millis(1); - assert!(!burst.newline_should_insert_instead_of_submit(t3)); - } -} diff --git a/codex-rs/tui2/src/bottom_pane/popup_consts.rs b/codex-rs/tui2/src/bottom_pane/popup_consts.rs deleted file mode 100644 index 2cabe389b1b..00000000000 --- a/codex-rs/tui2/src/bottom_pane/popup_consts.rs +++ /dev/null @@ -1,21 +0,0 @@ -//! Shared popup-related constants for bottom pane widgets. - -use crossterm::event::KeyCode; -use ratatui::text::Line; - -use crate::key_hint; - -/// Maximum number of rows any popup should attempt to display. -/// Keep this consistent across all popups for a uniform feel. -pub(crate) const MAX_POPUP_ROWS: usize = 8; - -/// Standard footer hint text used by popups. -pub(crate) fn standard_popup_hint_line() -> Line<'static> { - Line::from(vec![ - "Press ".into(), - key_hint::plain(KeyCode::Enter).into(), - " to confirm or ".into(), - key_hint::plain(KeyCode::Esc).into(), - " to go back".into(), - ]) -} diff --git a/codex-rs/tui2/src/bottom_pane/prompt_args.rs b/codex-rs/tui2/src/bottom_pane/prompt_args.rs deleted file mode 100644 index ade750f6713..00000000000 --- a/codex-rs/tui2/src/bottom_pane/prompt_args.rs +++ /dev/null @@ -1,854 +0,0 @@ -use codex_protocol::custom_prompts::CustomPrompt; -use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; -use codex_protocol::user_input::ByteRange; -use codex_protocol::user_input::TextElement; -use lazy_static::lazy_static; -use regex_lite::Regex; -use shlex::Shlex; -use std::collections::HashMap; -use std::collections::HashSet; - -lazy_static! { - static ref PROMPT_ARG_REGEX: Regex = - Regex::new(r"\$[A-Z][A-Z0-9_]*").unwrap_or_else(|_| std::process::abort()); -} - -#[derive(Debug)] -pub enum PromptArgsError { - MissingAssignment { token: String }, - MissingKey { token: String }, -} - -impl PromptArgsError { - fn describe(&self, command: &str) -> String { - match self { - PromptArgsError::MissingAssignment { token } => format!( - "Could not parse {command}: expected key=value but found '{token}'. Wrap values in double quotes if they contain spaces." - ), - PromptArgsError::MissingKey { token } => { - format!("Could not parse {command}: expected a name before '=' in '{token}'.") - } - } - } -} - -#[derive(Debug)] -pub enum PromptExpansionError { - Args { - command: String, - error: PromptArgsError, - }, - MissingArgs { - command: String, - missing: Vec, - }, -} - -impl PromptExpansionError { - pub fn user_message(&self) -> String { - match self { - PromptExpansionError::Args { command, error } => error.describe(command), - PromptExpansionError::MissingArgs { command, missing } => { - let list = missing.join(", "); - format!( - "Missing required args for {command}: {list}. Provide as key=value (quote values with spaces)." - ) - } - } - } -} - -/// Parse a first-line slash command of the form `/name `. -/// Returns `(name, rest_after_name, rest_offset)` if the line begins with `/` -/// and contains a non-empty name; otherwise returns `None`. -/// -/// `rest_offset` is the byte index into the original line where `rest_after_name` -/// starts after trimming leading whitespace (so `line[rest_offset..] == rest_after_name`). -pub fn parse_slash_name(line: &str) -> Option<(&str, &str, usize)> { - let stripped = line.strip_prefix('/')?; - let mut name_end_in_stripped = stripped.len(); - for (idx, ch) in stripped.char_indices() { - if ch.is_whitespace() { - name_end_in_stripped = idx; - break; - } - } - let name = &stripped[..name_end_in_stripped]; - if name.is_empty() { - return None; - } - let rest_untrimmed = &stripped[name_end_in_stripped..]; - let rest = rest_untrimmed.trim_start(); - let rest_start_in_stripped = name_end_in_stripped + (rest_untrimmed.len() - rest.len()); - // `stripped` is `line` without the leading '/', so add 1 to get the original offset. - let rest_offset = rest_start_in_stripped + 1; - Some((name, rest, rest_offset)) -} - -#[derive(Debug, Clone, PartialEq)] -pub struct PromptArg { - pub text: String, - pub text_elements: Vec, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct PromptExpansion { - pub text: String, - pub text_elements: Vec, -} - -/// Parse positional arguments using shlex semantics (supports quoted tokens). -/// -/// `text_elements` must be relative to `rest`. -pub fn parse_positional_args(rest: &str, text_elements: &[TextElement]) -> Vec { - parse_tokens_with_elements(rest, text_elements) -} - -/// Extracts the unique placeholder variable names from a prompt template. -/// -/// A placeholder is any token that matches the pattern `$[A-Z][A-Z0-9_]*` -/// (for example `$USER`). The function returns the variable names without -/// the leading `$`, de-duplicated and in the order of first appearance. -pub fn prompt_argument_names(content: &str) -> Vec { - let mut seen = HashSet::new(); - let mut names = Vec::new(); - for m in PROMPT_ARG_REGEX.find_iter(content) { - if m.start() > 0 && content.as_bytes()[m.start() - 1] == b'$' { - continue; - } - let name = &content[m.start() + 1..m.end()]; - // Exclude special positional aggregate token from named args. - if name == "ARGUMENTS" { - continue; - } - let name = name.to_string(); - if seen.insert(name.clone()) { - names.push(name); - } - } - names -} - -/// Parses the `key=value` pairs that follow a custom prompt name. -/// -/// The input is split using shlex rules, so quoted values are supported -/// (for example `USER="Alice Smith"`). The function returns a map of parsed -/// arguments, or an error if a token is missing `=` or if the key is empty. -pub fn parse_prompt_inputs( - rest: &str, - text_elements: &[TextElement], -) -> Result, PromptArgsError> { - let mut map = HashMap::new(); - if rest.trim().is_empty() { - return Ok(map); - } - - // Tokenize the rest of the command using shlex rules, but keep text element - // ranges relative to each emitted token. - for token in parse_tokens_with_elements(rest, text_elements) { - let Some((key, value)) = token.text.split_once('=') else { - return Err(PromptArgsError::MissingAssignment { token: token.text }); - }; - if key.is_empty() { - return Err(PromptArgsError::MissingKey { token: token.text }); - } - // The token is `key=value`; translate element ranges into the value-only - // coordinate space by subtracting the `key=` prefix length. - let value_start = key.len() + 1; - let value_elements = token - .text_elements - .iter() - .filter_map(|elem| shift_text_element_left(elem, value_start)) - .collect(); - map.insert( - key.to_string(), - PromptArg { - text: value.to_string(), - text_elements: value_elements, - }, - ); - } - Ok(map) -} - -/// Expands a message of the form `/prompts:name [value] [value] …` using a matching saved prompt. -/// -/// If the text does not start with `/prompts:`, or if no prompt named `name` exists, -/// the function returns `Ok(None)`. On success it returns -/// `Ok(Some(expanded))`; otherwise it returns a descriptive error. -pub fn expand_custom_prompt( - text: &str, - text_elements: &[TextElement], - custom_prompts: &[CustomPrompt], -) -> Result, PromptExpansionError> { - let Some((name, rest, rest_offset)) = parse_slash_name(text) else { - return Ok(None); - }; - - // Only handle custom prompts when using the explicit prompts prefix with a colon. - let Some(prompt_name) = name.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) else { - return Ok(None); - }; - - let prompt = match custom_prompts.iter().find(|p| p.name == prompt_name) { - Some(prompt) => prompt, - None => return Ok(None), - }; - // If there are named placeholders, expect key=value inputs. - let required = prompt_argument_names(&prompt.content); - let local_elements: Vec = text_elements - .iter() - .filter_map(|elem| { - let mut shifted = shift_text_element_left(elem, rest_offset)?; - if shifted.byte_range.start >= rest.len() { - return None; - } - let end = shifted.byte_range.end.min(rest.len()); - shifted.byte_range.end = end; - (shifted.byte_range.start < shifted.byte_range.end).then_some(shifted) - }) - .collect(); - if !required.is_empty() { - let inputs = parse_prompt_inputs(rest, &local_elements).map_err(|error| { - PromptExpansionError::Args { - command: format!("/{name}"), - error, - } - })?; - let missing: Vec = required - .into_iter() - .filter(|k| !inputs.contains_key(k)) - .collect(); - if !missing.is_empty() { - return Err(PromptExpansionError::MissingArgs { - command: format!("/{name}"), - missing, - }); - } - let (text, elements) = expand_named_placeholders_with_elements(&prompt.content, &inputs); - return Ok(Some(PromptExpansion { - text, - text_elements: elements, - })); - } - - // Otherwise, treat it as numeric/positional placeholder prompt (or none). - let pos_args = parse_positional_args(rest, &local_elements); - Ok(Some(expand_numeric_placeholders( - &prompt.content, - &pos_args, - ))) -} - -/// Detect whether `content` contains numeric placeholders ($1..$9) or `$ARGUMENTS`. -pub fn prompt_has_numeric_placeholders(content: &str) -> bool { - if content.contains("$ARGUMENTS") { - return true; - } - let bytes = content.as_bytes(); - let mut i = 0; - while i + 1 < bytes.len() { - if bytes[i] == b'$' { - let b1 = bytes[i + 1]; - if (b'1'..=b'9').contains(&b1) { - return true; - } - } - i += 1; - } - false -} - -/// Extract positional arguments from a composer first line like "/name a b" for a given prompt name. -/// Returns empty when the command name does not match or when there are no args. -pub fn extract_positional_args_for_prompt_line( - line: &str, - prompt_name: &str, - text_elements: &[TextElement], -) -> Vec { - let trimmed = line.trim_start(); - let trim_offset = line.len() - trimmed.len(); - let Some((name, rest, rest_offset)) = parse_slash_name(trimmed) else { - return Vec::new(); - }; - // Require the explicit prompts prefix for custom prompt invocations. - let Some(after_prefix) = name.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) else { - return Vec::new(); - }; - if after_prefix != prompt_name { - return Vec::new(); - } - let rest_trimmed_start = rest.trim_start(); - let args_str = rest_trimmed_start.trim_end(); - if args_str.is_empty() { - return Vec::new(); - } - let args_offset = trim_offset + rest_offset + (rest.len() - rest_trimmed_start.len()); - let local_elements: Vec = text_elements - .iter() - .filter_map(|elem| { - let mut shifted = shift_text_element_left(elem, args_offset)?; - if shifted.byte_range.start >= args_str.len() { - return None; - } - let end = shifted.byte_range.end.min(args_str.len()); - shifted.byte_range.end = end; - (shifted.byte_range.start < shifted.byte_range.end).then_some(shifted) - }) - .collect(); - parse_positional_args(args_str, &local_elements) -} - -/// If the prompt only uses numeric placeholders and the first line contains -/// positional args for it, expand and return Some(expanded); otherwise None. -pub fn expand_if_numeric_with_positional_args( - prompt: &CustomPrompt, - first_line: &str, - text_elements: &[TextElement], -) -> Option { - if !prompt_argument_names(&prompt.content).is_empty() { - return None; - } - if !prompt_has_numeric_placeholders(&prompt.content) { - return None; - } - let args = extract_positional_args_for_prompt_line(first_line, &prompt.name, text_elements); - if args.is_empty() { - return None; - } - Some(expand_numeric_placeholders(&prompt.content, &args)) -} - -/// Expand `$1..$9` and `$ARGUMENTS` in `content` with values from `args`. -pub fn expand_numeric_placeholders(content: &str, args: &[PromptArg]) -> PromptExpansion { - let mut out = String::with_capacity(content.len()); - let mut out_elements = Vec::new(); - let mut i = 0; - while let Some(off) = content[i..].find('$') { - let j = i + off; - out.push_str(&content[i..j]); - let rest = &content[j..]; - let bytes = rest.as_bytes(); - if bytes.len() >= 2 { - match bytes[1] { - b'$' => { - out.push_str("$$"); - i = j + 2; - continue; - } - b'1'..=b'9' => { - let idx = (bytes[1] - b'1') as usize; - if let Some(arg) = args.get(idx) { - append_arg_with_elements(&mut out, &mut out_elements, arg); - } - i = j + 2; - continue; - } - _ => {} - } - } - if rest.len() > "ARGUMENTS".len() && rest[1..].starts_with("ARGUMENTS") { - if !args.is_empty() { - append_joined_args_with_elements(&mut out, &mut out_elements, args); - } - i = j + 1 + "ARGUMENTS".len(); - continue; - } - out.push('$'); - i = j + 1; - } - out.push_str(&content[i..]); - PromptExpansion { - text: out, - text_elements: out_elements, - } -} - -fn parse_tokens_with_elements(rest: &str, text_elements: &[TextElement]) -> Vec { - let mut elements = text_elements.to_vec(); - elements.sort_by_key(|elem| elem.byte_range.start); - // Keep element placeholders intact across shlex splitting by replacing - // each element range with a unique sentinel token first. - let (rest_for_shlex, replacements) = replace_text_elements_with_sentinels(rest, &elements); - Shlex::new(&rest_for_shlex) - .map(|token| apply_replacements_to_token(token, &replacements)) - .collect() -} - -#[derive(Debug, Clone)] -struct ElementReplacement { - sentinel: String, - text: String, - placeholder: Option, -} - -/// Replace each text element range with a unique sentinel token. -/// -/// The sentinel is chosen so it will survive shlex tokenization as a single word. -fn replace_text_elements_with_sentinels( - rest: &str, - elements: &[TextElement], -) -> (String, Vec) { - let mut out = String::with_capacity(rest.len()); - let mut replacements = Vec::new(); - let mut cursor = 0; - - for (idx, elem) in elements.iter().enumerate() { - let start = elem.byte_range.start; - let end = elem.byte_range.end; - out.push_str(&rest[cursor..start]); - let mut sentinel = format!("__CODEX_ELEM_{idx}__"); - // Ensure we never collide with user content so a sentinel can't be mistaken for text. - while rest.contains(&sentinel) { - sentinel.push('_'); - } - out.push_str(&sentinel); - replacements.push(ElementReplacement { - sentinel, - text: rest[start..end].to_string(), - placeholder: elem.placeholder(rest).map(str::to_string), - }); - cursor = end; - } - - out.push_str(&rest[cursor..]); - (out, replacements) -} - -/// Rehydrate a shlex token by swapping sentinels back to the original text -/// and rebuilding text element ranges relative to the resulting token. -fn apply_replacements_to_token(token: String, replacements: &[ElementReplacement]) -> PromptArg { - if replacements.is_empty() { - return PromptArg { - text: token, - text_elements: Vec::new(), - }; - } - - let mut out = String::with_capacity(token.len()); - let mut out_elements = Vec::new(); - let mut cursor = 0; - - while cursor < token.len() { - let Some((offset, replacement)) = next_replacement(&token, cursor, replacements) else { - out.push_str(&token[cursor..]); - break; - }; - let start_in_token = cursor + offset; - out.push_str(&token[cursor..start_in_token]); - let start = out.len(); - out.push_str(&replacement.text); - let end = out.len(); - if start < end { - out_elements.push(TextElement::new( - ByteRange { start, end }, - replacement.placeholder.clone(), - )); - } - cursor = start_in_token + replacement.sentinel.len(); - } - - PromptArg { - text: out, - text_elements: out_elements, - } -} - -/// Find the earliest sentinel occurrence at or after `cursor`. -fn next_replacement<'a>( - token: &str, - cursor: usize, - replacements: &'a [ElementReplacement], -) -> Option<(usize, &'a ElementReplacement)> { - let slice = &token[cursor..]; - let mut best: Option<(usize, &'a ElementReplacement)> = None; - for replacement in replacements { - if let Some(pos) = slice.find(&replacement.sentinel) { - match best { - Some((best_pos, _)) if best_pos <= pos => {} - _ => best = Some((pos, replacement)), - } - } - } - best -} - -/// Shift a text element's byte range left by `offset`, returning `None` if empty. -/// -/// `offset` is the byte length of the prefix removed from the original text. -fn shift_text_element_left(elem: &TextElement, offset: usize) -> Option { - if elem.byte_range.end <= offset { - return None; - } - let start = elem.byte_range.start.saturating_sub(offset); - let end = elem.byte_range.end.saturating_sub(offset); - (start < end).then_some(elem.map_range(|_| ByteRange { start, end })) -} - -fn expand_named_placeholders_with_elements( - content: &str, - args: &HashMap, -) -> (String, Vec) { - let mut out = String::with_capacity(content.len()); - let mut out_elements = Vec::new(); - let mut cursor = 0; - for m in PROMPT_ARG_REGEX.find_iter(content) { - let start = m.start(); - let end = m.end(); - if start > 0 && content.as_bytes()[start - 1] == b'$' { - out.push_str(&content[cursor..end]); - cursor = end; - continue; - } - out.push_str(&content[cursor..start]); - cursor = end; - let key = &content[start + 1..end]; - if let Some(arg) = args.get(key) { - append_arg_with_elements(&mut out, &mut out_elements, arg); - } else { - out.push_str(&content[start..end]); - } - } - out.push_str(&content[cursor..]); - (out, out_elements) -} - -fn append_arg_with_elements( - out: &mut String, - out_elements: &mut Vec, - arg: &PromptArg, -) { - let start = out.len(); - out.push_str(&arg.text); - if arg.text_elements.is_empty() { - return; - } - out_elements.extend(arg.text_elements.iter().map(|elem| { - elem.map_range(|range| ByteRange { - start: start + range.start, - end: start + range.end, - }) - })); -} - -fn append_joined_args_with_elements( - out: &mut String, - out_elements: &mut Vec, - args: &[PromptArg], -) { - // `$ARGUMENTS` joins args with single spaces while preserving element ranges. - for (idx, arg) in args.iter().enumerate() { - if idx > 0 { - out.push(' '); - } - append_arg_with_elements(out, out_elements, arg); - } -} - -/// Constructs a command text for a custom prompt with arguments. -/// Returns the text and the cursor position (inside the first double quote). -pub fn prompt_command_with_arg_placeholders(name: &str, args: &[String]) -> (String, usize) { - let mut text = format!("/{PROMPTS_CMD_PREFIX}:{name}"); - let mut cursor: usize = text.len(); - for (i, arg) in args.iter().enumerate() { - text.push_str(format!(" {arg}=\"\"").as_str()); - if i == 0 { - cursor = text.len() - 1; // inside first "" - } - } - (text, cursor) -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - - #[test] - fn expand_arguments_basic() { - let prompts = vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "Review $USER changes on $BRANCH".to_string(), - description: None, - argument_hint: None, - }]; - - let out = expand_custom_prompt("/prompts:my-prompt USER=Alice BRANCH=main", &[], &prompts) - .unwrap(); - assert_eq!( - out, - Some(PromptExpansion { - text: "Review Alice changes on main".to_string(), - text_elements: Vec::new(), - }) - ); - } - - #[test] - fn quoted_values_ok() { - let prompts = vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "Pair $USER with $BRANCH".to_string(), - description: None, - argument_hint: None, - }]; - - let out = expand_custom_prompt( - "/prompts:my-prompt USER=\"Alice Smith\" BRANCH=dev-main", - &[], - &prompts, - ) - .unwrap(); - assert_eq!( - out, - Some(PromptExpansion { - text: "Pair Alice Smith with dev-main".to_string(), - text_elements: Vec::new(), - }) - ); - } - - #[test] - fn invalid_arg_token_reports_error() { - let prompts = vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "Review $USER changes".to_string(), - description: None, - argument_hint: None, - }]; - let err = expand_custom_prompt("/prompts:my-prompt USER=Alice stray", &[], &prompts) - .unwrap_err() - .user_message(); - assert!(err.contains("expected key=value")); - } - - #[test] - fn missing_required_args_reports_error() { - let prompts = vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "Review $USER changes on $BRANCH".to_string(), - description: None, - argument_hint: None, - }]; - let err = expand_custom_prompt("/prompts:my-prompt USER=Alice", &[], &prompts) - .unwrap_err() - .user_message(); - assert!(err.to_lowercase().contains("missing required args")); - assert!(err.contains("BRANCH")); - } - - #[test] - fn escaped_placeholder_is_ignored() { - assert_eq!( - prompt_argument_names("literal $$USER"), - Vec::::new() - ); - assert_eq!( - prompt_argument_names("literal $$USER and $REAL"), - vec!["REAL".to_string()] - ); - } - - #[test] - fn escaped_placeholder_remains_literal() { - let prompts = vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "literal $$USER".to_string(), - description: None, - argument_hint: None, - }]; - - let out = expand_custom_prompt("/prompts:my-prompt", &[], &prompts).unwrap(); - assert_eq!( - out, - Some(PromptExpansion { - text: "literal $$USER".to_string(), - text_elements: Vec::new(), - }) - ); - } - - #[test] - fn positional_args_treat_placeholder_with_spaces_as_single_token() { - let placeholder = "[Image #1]"; - let rest = format!("alpha {placeholder} beta"); - let start = rest.find(placeholder).expect("placeholder"); - let end = start + placeholder.len(); - let text_elements = vec![TextElement::new( - ByteRange { start, end }, - Some(placeholder.to_string()), - )]; - - let args = parse_positional_args(&rest, &text_elements); - assert_eq!( - args, - vec![ - PromptArg { - text: "alpha".to_string(), - text_elements: Vec::new(), - }, - PromptArg { - text: placeholder.to_string(), - text_elements: vec![TextElement::new( - ByteRange { - start: 0, - end: placeholder.len(), - }, - Some(placeholder.to_string()), - )], - }, - PromptArg { - text: "beta".to_string(), - text_elements: Vec::new(), - } - ] - ); - } - - #[test] - fn extract_positional_args_shifts_element_offsets_into_args_str() { - let placeholder = "[Image #1]"; - let line = format!(" /{PROMPTS_CMD_PREFIX}:my-prompt alpha {placeholder} beta "); - let start = line.find(placeholder).expect("placeholder"); - let end = start + placeholder.len(); - let text_elements = vec![TextElement::new( - ByteRange { start, end }, - Some(placeholder.to_string()), - )]; - - let args = extract_positional_args_for_prompt_line(&line, "my-prompt", &text_elements); - assert_eq!( - args, - vec![ - PromptArg { - text: "alpha".to_string(), - text_elements: Vec::new(), - }, - PromptArg { - text: placeholder.to_string(), - text_elements: vec![TextElement::new( - ByteRange { - start: 0, - end: placeholder.len(), - }, - Some(placeholder.to_string()), - )], - }, - PromptArg { - text: "beta".to_string(), - text_elements: Vec::new(), - } - ] - ); - } - - #[test] - fn key_value_args_treat_placeholder_with_spaces_as_single_token() { - let placeholder = "[Image #1]"; - let rest = format!("IMG={placeholder} NOTE=hello"); - let start = rest.find(placeholder).expect("placeholder"); - let end = start + placeholder.len(); - let text_elements = vec![TextElement::new( - ByteRange { start, end }, - Some(placeholder.to_string()), - )]; - - let args = parse_prompt_inputs(&rest, &text_elements).expect("inputs"); - assert_eq!( - args.get("IMG"), - Some(&PromptArg { - text: placeholder.to_string(), - text_elements: vec![TextElement::new( - ByteRange { - start: 0, - end: placeholder.len(), - }, - Some(placeholder.to_string()), - )], - }) - ); - assert_eq!( - args.get("NOTE"), - Some(&PromptArg { - text: "hello".to_string(), - text_elements: Vec::new(), - }) - ); - } - - #[test] - fn positional_args_allow_placeholder_inside_quotes() { - let placeholder = "[Image #1]"; - let rest = format!("alpha \"see {placeholder} here\" beta"); - let start = rest.find(placeholder).expect("placeholder"); - let end = start + placeholder.len(); - let text_elements = vec![TextElement::new( - ByteRange { start, end }, - Some(placeholder.to_string()), - )]; - - let args = parse_positional_args(&rest, &text_elements); - assert_eq!( - args, - vec![ - PromptArg { - text: "alpha".to_string(), - text_elements: Vec::new(), - }, - PromptArg { - text: format!("see {placeholder} here"), - text_elements: vec![TextElement::new( - ByteRange { - start: "see ".len(), - end: "see ".len() + placeholder.len(), - }, - Some(placeholder.to_string()), - )], - }, - PromptArg { - text: "beta".to_string(), - text_elements: Vec::new(), - } - ] - ); - } - - #[test] - fn key_value_args_allow_placeholder_inside_quotes() { - let placeholder = "[Image #1]"; - let rest = format!("IMG=\"see {placeholder} here\" NOTE=ok"); - let start = rest.find(placeholder).expect("placeholder"); - let end = start + placeholder.len(); - let text_elements = vec![TextElement::new( - ByteRange { start, end }, - Some(placeholder.to_string()), - )]; - - let args = parse_prompt_inputs(&rest, &text_elements).expect("inputs"); - assert_eq!( - args.get("IMG"), - Some(&PromptArg { - text: format!("see {placeholder} here"), - text_elements: vec![TextElement::new( - ByteRange { - start: "see ".len(), - end: "see ".len() + placeholder.len(), - }, - Some(placeholder.to_string()), - )], - }) - ); - assert_eq!( - args.get("NOTE"), - Some(&PromptArg { - text: "ok".to_string(), - text_elements: Vec::new(), - }) - ); - } -} diff --git a/codex-rs/tui2/src/bottom_pane/queued_user_messages.rs b/codex-rs/tui2/src/bottom_pane/queued_user_messages.rs deleted file mode 100644 index ae33aeada47..00000000000 --- a/codex-rs/tui2/src/bottom_pane/queued_user_messages.rs +++ /dev/null @@ -1,157 +0,0 @@ -use crossterm::event::KeyCode; -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -use ratatui::style::Stylize; -use ratatui::text::Line; -use ratatui::widgets::Paragraph; - -use crate::key_hint; -use crate::render::renderable::Renderable; -use crate::wrapping::RtOptions; -use crate::wrapping::word_wrap_lines; - -/// Widget that displays a list of user messages queued while a turn is in progress. -pub(crate) struct QueuedUserMessages { - pub messages: Vec, -} - -impl QueuedUserMessages { - pub(crate) fn new() -> Self { - Self { - messages: Vec::new(), - } - } - - fn as_renderable(&self, width: u16) -> Box { - if self.messages.is_empty() || width < 4 { - return Box::new(()); - } - - let mut lines = vec![]; - - for message in &self.messages { - let wrapped = word_wrap_lines( - message.lines().map(|line| line.dim().italic()), - RtOptions::new(width as usize) - .initial_indent(Line::from(" ↳ ".dim())) - .subsequent_indent(Line::from(" ")), - ); - let len = wrapped.len(); - for line in wrapped.into_iter().take(3) { - lines.push(line); - } - if len > 3 { - lines.push(Line::from(" …".dim().italic())); - } - } - - lines.push( - Line::from(vec![ - " ".into(), - key_hint::alt(KeyCode::Up).into(), - " edit".into(), - ]) - .dim(), - ); - - Paragraph::new(lines).into() - } -} - -impl Renderable for QueuedUserMessages { - fn render(&self, area: Rect, buf: &mut Buffer) { - if area.is_empty() { - return; - } - - self.as_renderable(area.width).render(area, buf); - } - - fn desired_height(&self, width: u16) -> u16 { - self.as_renderable(width).desired_height(width) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use insta::assert_snapshot; - use pretty_assertions::assert_eq; - - #[test] - fn desired_height_empty() { - let queue = QueuedUserMessages::new(); - assert_eq!(queue.desired_height(40), 0); - } - - #[test] - fn desired_height_one_message() { - let mut queue = QueuedUserMessages::new(); - queue.messages.push("Hello, world!".to_string()); - assert_eq!(queue.desired_height(40), 2); - } - - #[test] - fn render_one_message() { - let mut queue = QueuedUserMessages::new(); - queue.messages.push("Hello, world!".to_string()); - let width = 40; - let height = queue.desired_height(width); - let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); - queue.render(Rect::new(0, 0, width, height), &mut buf); - assert_snapshot!("render_one_message", format!("{buf:?}")); - } - - #[test] - fn render_two_messages() { - let mut queue = QueuedUserMessages::new(); - queue.messages.push("Hello, world!".to_string()); - queue.messages.push("This is another message".to_string()); - let width = 40; - let height = queue.desired_height(width); - let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); - queue.render(Rect::new(0, 0, width, height), &mut buf); - assert_snapshot!("render_two_messages", format!("{buf:?}")); - } - - #[test] - fn render_more_than_three_messages() { - let mut queue = QueuedUserMessages::new(); - queue.messages.push("Hello, world!".to_string()); - queue.messages.push("This is another message".to_string()); - queue.messages.push("This is a third message".to_string()); - queue.messages.push("This is a fourth message".to_string()); - let width = 40; - let height = queue.desired_height(width); - let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); - queue.render(Rect::new(0, 0, width, height), &mut buf); - assert_snapshot!("render_more_than_three_messages", format!("{buf:?}")); - } - - #[test] - fn render_wrapped_message() { - let mut queue = QueuedUserMessages::new(); - queue - .messages - .push("This is a longer message that should be wrapped".to_string()); - queue.messages.push("This is another message".to_string()); - let width = 40; - let height = queue.desired_height(width); - let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); - queue.render(Rect::new(0, 0, width, height), &mut buf); - assert_snapshot!("render_wrapped_message", format!("{buf:?}")); - } - - #[test] - fn render_many_line_message() { - let mut queue = QueuedUserMessages::new(); - queue - .messages - .push("This is\na message\nwith many\nlines".to_string()); - let width = 40; - let height = queue.desired_height(width); - let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); - queue.render(Rect::new(0, 0, width, height), &mut buf); - assert_snapshot!("render_many_line_message", format!("{buf:?}")); - } -} diff --git a/codex-rs/tui2/src/bottom_pane/scroll_state.rs b/codex-rs/tui2/src/bottom_pane/scroll_state.rs deleted file mode 100644 index a9728d1a0db..00000000000 --- a/codex-rs/tui2/src/bottom_pane/scroll_state.rs +++ /dev/null @@ -1,115 +0,0 @@ -/// Generic scroll/selection state for a vertical list menu. -/// -/// Encapsulates the common behavior of a selectable list that supports: -/// - Optional selection (None when list is empty) -/// - Wrap-around navigation on Up/Down -/// - Maintaining a scroll window (`scroll_top`) so the selected row stays visible -#[derive(Debug, Default, Clone, Copy)] -pub(crate) struct ScrollState { - pub selected_idx: Option, - pub scroll_top: usize, -} - -impl ScrollState { - pub fn new() -> Self { - Self { - selected_idx: None, - scroll_top: 0, - } - } - - /// Reset selection and scroll. - pub fn reset(&mut self) { - self.selected_idx = None; - self.scroll_top = 0; - } - - /// Clamp selection to be within the [0, len-1] range, or None when empty. - pub fn clamp_selection(&mut self, len: usize) { - self.selected_idx = match len { - 0 => None, - _ => Some(self.selected_idx.unwrap_or(0).min(len - 1)), - }; - if len == 0 { - self.scroll_top = 0; - } - } - - /// Move selection up by one, wrapping to the bottom when necessary. - pub fn move_up_wrap(&mut self, len: usize) { - if len == 0 { - self.selected_idx = None; - self.scroll_top = 0; - return; - } - self.selected_idx = Some(match self.selected_idx { - Some(idx) if idx > 0 => idx - 1, - Some(_) => len - 1, - None => 0, - }); - } - - /// Move selection down by one, wrapping to the top when necessary. - pub fn move_down_wrap(&mut self, len: usize) { - if len == 0 { - self.selected_idx = None; - self.scroll_top = 0; - return; - } - self.selected_idx = Some(match self.selected_idx { - Some(idx) if idx + 1 < len => idx + 1, - _ => 0, - }); - } - - /// Adjust `scroll_top` so that the current `selected_idx` is visible within - /// the window of `visible_rows`. - pub fn ensure_visible(&mut self, len: usize, visible_rows: usize) { - if len == 0 || visible_rows == 0 { - self.scroll_top = 0; - return; - } - if let Some(sel) = self.selected_idx { - if sel < self.scroll_top { - self.scroll_top = sel; - } else { - let bottom = self.scroll_top + visible_rows - 1; - if sel > bottom { - self.scroll_top = sel + 1 - visible_rows; - } - } - } else { - self.scroll_top = 0; - } - } -} - -#[cfg(test)] -mod tests { - use super::ScrollState; - - #[test] - fn wrap_navigation_and_visibility() { - let mut s = ScrollState::new(); - let len = 10; - let vis = 5; - - s.clamp_selection(len); - assert_eq!(s.selected_idx, Some(0)); - s.ensure_visible(len, vis); - assert_eq!(s.scroll_top, 0); - - s.move_up_wrap(len); - s.ensure_visible(len, vis); - assert_eq!(s.selected_idx, Some(len - 1)); - match s.selected_idx { - Some(sel) => assert!(s.scroll_top <= sel), - None => panic!("expected Some(selected_idx) after wrap"), - } - - s.move_down_wrap(len); - s.ensure_visible(len, vis); - assert_eq!(s.selected_idx, Some(0)); - assert_eq!(s.scroll_top, 0); - } -} diff --git a/codex-rs/tui2/src/bottom_pane/selection_popup_common.rs b/codex-rs/tui2/src/bottom_pane/selection_popup_common.rs deleted file mode 100644 index b340e18883f..00000000000 --- a/codex-rs/tui2/src/bottom_pane/selection_popup_common.rs +++ /dev/null @@ -1,419 +0,0 @@ -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -// Note: Table-based layout previously used Constraint; the manual renderer -// below no longer requires it. -use ratatui::style::Color; -use ratatui::style::Style; -use ratatui::style::Stylize; -use ratatui::text::Line; -use ratatui::text::Span; -use ratatui::widgets::Widget; -use unicode_width::UnicodeWidthChar; -use unicode_width::UnicodeWidthStr; - -use crate::key_hint::KeyBinding; - -use super::scroll_state::ScrollState; - -/// A generic representation of a display row for selection popups. -pub(crate) struct GenericDisplayRow { - pub name: String, - pub display_shortcut: Option, - pub match_indices: Option>, // indices to bold (char positions) - pub description: Option, // optional grey text after the name - pub wrap_indent: Option, // optional indent for wrapped lines -} - -pub(crate) fn wrap_styled_line<'a>(line: &'a Line<'a>, width: u16) -> Vec> { - use crate::wrapping::RtOptions; - use crate::wrapping::word_wrap_line; - - let width = width.max(1) as usize; - let opts = RtOptions::new(width) - .initial_indent(Line::from("")) - .subsequent_indent(Line::from("")); - word_wrap_line(line, opts) -} - -fn line_width(line: &Line<'_>) -> usize { - line.iter() - .map(|span| UnicodeWidthStr::width(span.content.as_ref())) - .sum() -} - -fn truncate_line_to_width(line: Line<'static>, max_width: usize) -> Line<'static> { - if max_width == 0 { - return Line::from(Vec::>::new()); - } - - let mut used = 0usize; - let mut spans_out: Vec> = Vec::new(); - - for span in line.spans { - let text = span.content.into_owned(); - let style = span.style; - let span_width = UnicodeWidthStr::width(text.as_str()); - - if span_width == 0 { - spans_out.push(Span::styled(text, style)); - continue; - } - - if used >= max_width { - break; - } - - if used + span_width <= max_width { - used += span_width; - spans_out.push(Span::styled(text, style)); - continue; - } - - let mut truncated = String::new(); - for ch in text.chars() { - let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); - if used + ch_width > max_width { - break; - } - truncated.push(ch); - used += ch_width; - } - - if !truncated.is_empty() { - spans_out.push(Span::styled(truncated, style)); - } - - break; - } - - Line::from(spans_out) -} - -fn truncate_line_with_ellipsis_if_overflow(line: Line<'static>, max_width: usize) -> Line<'static> { - if max_width == 0 { - return Line::from(Vec::>::new()); - } - - let width = line_width(&line); - if width <= max_width { - return line; - } - - let truncated = truncate_line_to_width(line, max_width.saturating_sub(1)); - let mut spans = truncated.spans; - let ellipsis_style = spans.last().map(|span| span.style).unwrap_or_default(); - spans.push(Span::styled("…", ellipsis_style)); - Line::from(spans) -} - -/// Compute a shared description-column start based on the widest visible name -/// plus two spaces of padding. Ensures at least one column is left for the -/// description. -fn compute_desc_col( - rows_all: &[GenericDisplayRow], - start_idx: usize, - visible_items: usize, - content_width: u16, -) -> usize { - let visible_range = start_idx..(start_idx + visible_items); - let max_name_width = rows_all - .iter() - .enumerate() - .filter(|(i, _)| visible_range.contains(i)) - .map(|(_, r)| Line::from(r.name.clone()).width()) - .max() - .unwrap_or(0); - let mut desc_col = max_name_width.saturating_add(2); - if (desc_col as u16) >= content_width { - desc_col = content_width.saturating_sub(1) as usize; - } - desc_col -} - -/// Determine how many spaces to indent wrapped lines for a row. -fn wrap_indent(row: &GenericDisplayRow, desc_col: usize, max_width: u16) -> usize { - let max_indent = max_width.saturating_sub(1) as usize; - let indent = row.wrap_indent.unwrap_or_else(|| { - if row.description.is_some() { - desc_col - } else { - 0 - } - }); - indent.min(max_indent) -} - -/// Build the full display line for a row with the description padded to start -/// at `desc_col`. Applies fuzzy-match bolding when indices are present and -/// dims the description. -fn build_full_line(row: &GenericDisplayRow, desc_col: usize) -> Line<'static> { - // Enforce single-line name: allow at most desc_col - 2 cells for name, - // reserving two spaces before the description column. - let name_limit = row - .description - .as_ref() - .map(|_| desc_col.saturating_sub(2)) - .unwrap_or(usize::MAX); - - let mut name_spans: Vec = Vec::with_capacity(row.name.len()); - let mut used_width = 0usize; - let mut truncated = false; - - if let Some(idxs) = row.match_indices.as_ref() { - let mut idx_iter = idxs.iter().peekable(); - for (char_idx, ch) in row.name.chars().enumerate() { - let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0); - let next_width = used_width.saturating_add(ch_w); - if next_width > name_limit { - truncated = true; - break; - } - used_width = next_width; - - if idx_iter.peek().is_some_and(|next| **next == char_idx) { - idx_iter.next(); - name_spans.push(ch.to_string().bold()); - } else { - name_spans.push(ch.to_string().into()); - } - } - } else { - for ch in row.name.chars() { - let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0); - let next_width = used_width.saturating_add(ch_w); - if next_width > name_limit { - truncated = true; - break; - } - used_width = next_width; - name_spans.push(ch.to_string().into()); - } - } - - if truncated { - // If there is at least one cell available, add an ellipsis. - // When name_limit is 0, we still show an ellipsis to indicate truncation. - name_spans.push("…".into()); - } - - let this_name_width = Line::from(name_spans.clone()).width(); - let mut full_spans: Vec = name_spans; - if let Some(display_shortcut) = row.display_shortcut { - full_spans.push(" (".into()); - full_spans.push(display_shortcut.into()); - full_spans.push(")".into()); - } - if let Some(desc) = row.description.as_ref() { - let gap = desc_col.saturating_sub(this_name_width); - if gap > 0 { - full_spans.push(" ".repeat(gap).into()); - } - full_spans.push(desc.clone().dim()); - } - Line::from(full_spans) -} - -/// Render a list of rows using the provided ScrollState, with shared styling -/// and behavior for selection popups. -pub(crate) fn render_rows( - area: Rect, - buf: &mut Buffer, - rows_all: &[GenericDisplayRow], - state: &ScrollState, - max_results: usize, - empty_message: &str, -) { - if rows_all.is_empty() { - if area.height > 0 { - Line::from(empty_message.dim().italic()).render(area, buf); - } - return; - } - - // Determine which logical rows (items) are visible given the selection and - // the max_results clamp. Scrolling is still item-based for simplicity. - let visible_items = max_results - .min(rows_all.len()) - .min(area.height.max(1) as usize); - - let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1)); - if let Some(sel) = state.selected_idx { - if sel < start_idx { - start_idx = sel; - } else if visible_items > 0 { - let bottom = start_idx + visible_items - 1; - if sel > bottom { - start_idx = sel + 1 - visible_items; - } - } - } - - let desc_col = compute_desc_col(rows_all, start_idx, visible_items, area.width); - - // Render items, wrapping descriptions and aligning wrapped lines under the - // shared description column. Stop when we run out of vertical space. - let mut cur_y = area.y; - for (i, row) in rows_all - .iter() - .enumerate() - .skip(start_idx) - .take(visible_items) - { - if cur_y >= area.y + area.height { - break; - } - - let mut full_line = build_full_line(row, desc_col); - if Some(i) == state.selected_idx { - // Match previous behavior: cyan + bold for the selected row. - // Reset the style first to avoid inheriting dim from keyboard shortcuts. - full_line.spans.iter_mut().for_each(|span| { - span.style = Style::default().fg(Color::Cyan).bold(); - }); - } - - // Wrap with subsequent indent aligned to the description column. - use crate::wrapping::RtOptions; - use crate::wrapping::word_wrap_line; - let continuation_indent = wrap_indent(row, desc_col, area.width); - let options = RtOptions::new(area.width as usize) - .initial_indent(Line::from("")) - .subsequent_indent(Line::from(" ".repeat(continuation_indent))); - let wrapped = word_wrap_line(&full_line, options); - - // Render the wrapped lines. - for line in wrapped { - if cur_y >= area.y + area.height { - break; - } - line.render( - Rect { - x: area.x, - y: cur_y, - width: area.width, - height: 1, - }, - buf, - ); - cur_y = cur_y.saturating_add(1); - } - } -} - -/// Render rows as a single line each (no wrapping), truncating overflow with an ellipsis. -pub(crate) fn render_rows_single_line( - area: Rect, - buf: &mut Buffer, - rows_all: &[GenericDisplayRow], - state: &ScrollState, - max_results: usize, - empty_message: &str, -) { - if rows_all.is_empty() { - if area.height > 0 { - Line::from(empty_message.dim().italic()).render(area, buf); - } - return; - } - - let visible_items = max_results - .min(rows_all.len()) - .min(area.height.max(1) as usize); - - let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1)); - if let Some(sel) = state.selected_idx { - if sel < start_idx { - start_idx = sel; - } else if visible_items > 0 { - let bottom = start_idx + visible_items - 1; - if sel > bottom { - start_idx = sel + 1 - visible_items; - } - } - } - - let desc_col = compute_desc_col(rows_all, start_idx, visible_items, area.width); - - let mut cur_y = area.y; - for (i, row) in rows_all - .iter() - .enumerate() - .skip(start_idx) - .take(visible_items) - { - if cur_y >= area.y + area.height { - break; - } - - let mut full_line = build_full_line(row, desc_col); - if Some(i) == state.selected_idx { - full_line.spans.iter_mut().for_each(|span| { - span.style = Style::default().fg(Color::Cyan).bold(); - }); - } - - let full_line = truncate_line_with_ellipsis_if_overflow(full_line, area.width as usize); - full_line.render( - Rect { - x: area.x, - y: cur_y, - width: area.width, - height: 1, - }, - buf, - ); - cur_y = cur_y.saturating_add(1); - } -} - -/// Compute the number of terminal rows required to render up to `max_results` -/// items from `rows_all` given the current scroll/selection state and the -/// available `width`. Accounts for description wrapping and alignment so the -/// caller can allocate sufficient vertical space. -pub(crate) fn measure_rows_height( - rows_all: &[GenericDisplayRow], - state: &ScrollState, - max_results: usize, - width: u16, -) -> u16 { - if rows_all.is_empty() { - return 1; // placeholder "no matches" line - } - - let content_width = width.saturating_sub(1).max(1); - - let visible_items = max_results.min(rows_all.len()); - let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1)); - if let Some(sel) = state.selected_idx { - if sel < start_idx { - start_idx = sel; - } else if visible_items > 0 { - let bottom = start_idx + visible_items - 1; - if sel > bottom { - start_idx = sel + 1 - visible_items; - } - } - } - - let desc_col = compute_desc_col(rows_all, start_idx, visible_items, content_width); - - use crate::wrapping::RtOptions; - use crate::wrapping::word_wrap_line; - let mut total: u16 = 0; - for row in rows_all - .iter() - .enumerate() - .skip(start_idx) - .take(visible_items) - .map(|(_, r)| r) - { - let full_line = build_full_line(row, desc_col); - let continuation_indent = wrap_indent(row, desc_col, content_width); - let opts = RtOptions::new(content_width as usize) - .initial_indent(Line::from("")) - .subsequent_indent(Line::from(" ".repeat(continuation_indent))); - let wrapped_lines = word_wrap_line(&full_line, opts).len(); - total = total.saturating_add(wrapped_lines as u16); - } - total.max(1) -} diff --git a/codex-rs/tui2/src/bottom_pane/skill_popup.rs b/codex-rs/tui2/src/bottom_pane/skill_popup.rs deleted file mode 100644 index d4d4a6828f2..00000000000 --- a/codex-rs/tui2/src/bottom_pane/skill_popup.rs +++ /dev/null @@ -1,196 +0,0 @@ -use crossterm::event::KeyCode; -use ratatui::buffer::Buffer; -use ratatui::layout::Constraint; -use ratatui::layout::Layout; -use ratatui::layout::Rect; -use ratatui::text::Line; -use ratatui::widgets::Widget; -use ratatui::widgets::WidgetRef; - -use super::popup_consts::MAX_POPUP_ROWS; -use super::scroll_state::ScrollState; -use super::selection_popup_common::GenericDisplayRow; -use super::selection_popup_common::render_rows_single_line; -use crate::key_hint; -use crate::render::Insets; -use crate::render::RectExt; -use codex_common::fuzzy_match::fuzzy_match; -use codex_core::skills::model::SkillMetadata; - -use crate::text_formatting::truncate_text; - -pub(crate) struct SkillPopup { - query: String, - skills: Vec, - state: ScrollState, -} - -impl SkillPopup { - pub(crate) fn new(skills: Vec) -> Self { - Self { - query: String::new(), - skills, - state: ScrollState::new(), - } - } - - pub(crate) fn set_skills(&mut self, skills: Vec) { - self.skills = skills; - self.clamp_selection(); - } - - pub(crate) fn set_query(&mut self, query: &str) { - self.query = query.to_string(); - self.clamp_selection(); - } - - pub(crate) fn calculate_required_height(&self, _width: u16) -> u16 { - let rows = self.rows_from_matches(self.filtered()); - let visible = rows.len().clamp(1, MAX_POPUP_ROWS); - (visible as u16).saturating_add(2) - } - - pub(crate) fn move_up(&mut self) { - let len = self.filtered_items().len(); - self.state.move_up_wrap(len); - self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); - } - - pub(crate) fn move_down(&mut self) { - let len = self.filtered_items().len(); - self.state.move_down_wrap(len); - self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); - } - - pub(crate) fn selected_skill(&self) -> Option<&SkillMetadata> { - let matches = self.filtered_items(); - let idx = self.state.selected_idx?; - let skill_idx = matches.get(idx)?; - self.skills.get(*skill_idx) - } - - fn clamp_selection(&mut self) { - let len = self.filtered_items().len(); - self.state.clamp_selection(len); - self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); - } - - fn filtered_items(&self) -> Vec { - self.filtered().into_iter().map(|(idx, _, _)| idx).collect() - } - - fn rows_from_matches( - &self, - matches: Vec<(usize, Option>, i32)>, - ) -> Vec { - matches - .into_iter() - .map(|(idx, indices, _score)| { - let skill = &self.skills[idx]; - let name = truncate_text(skill_display_name(skill), 21); - let description = skill_description(skill).to_string(); - GenericDisplayRow { - name, - match_indices: indices, - display_shortcut: None, - description: Some(description), - wrap_indent: None, - } - }) - .collect() - } - - fn filtered(&self) -> Vec<(usize, Option>, i32)> { - let filter = self.query.trim(); - let mut out: Vec<(usize, Option>, i32)> = Vec::new(); - - if filter.is_empty() { - for (idx, _skill) in self.skills.iter().enumerate() { - out.push((idx, None, 0)); - } - return out; - } - - for (idx, skill) in self.skills.iter().enumerate() { - let display_name = skill_display_name(skill); - if let Some((indices, score)) = fuzzy_match(display_name, filter) { - out.push((idx, Some(indices), score)); - } else if display_name != skill.name - && let Some((_indices, score)) = fuzzy_match(&skill.name, filter) - { - out.push((idx, None, score)); - } - } - - out.sort_by(|a, b| { - a.2.cmp(&b.2).then_with(|| { - let an = skill_display_name(&self.skills[a.0]); - let bn = skill_display_name(&self.skills[b.0]); - an.cmp(bn) - }) - }); - - out - } -} - -impl WidgetRef for SkillPopup { - fn render_ref(&self, area: Rect, buf: &mut Buffer) { - let (list_area, hint_area) = if area.height > 2 { - let [list_area, _spacer_area, hint_area] = Layout::vertical([ - Constraint::Length(area.height - 2), - Constraint::Length(1), - Constraint::Length(1), - ]) - .areas(area); - (list_area, Some(hint_area)) - } else { - (area, None) - }; - let rows = self.rows_from_matches(self.filtered()); - render_rows_single_line( - list_area.inset(Insets::tlbr(0, 2, 0, 0)), - buf, - &rows, - &self.state, - MAX_POPUP_ROWS, - "no skills", - ); - if let Some(hint_area) = hint_area { - let hint_area = Rect { - x: hint_area.x + 2, - y: hint_area.y, - width: hint_area.width.saturating_sub(2), - height: hint_area.height, - }; - skill_popup_hint_line().render(hint_area, buf); - } - } -} - -fn skill_popup_hint_line() -> Line<'static> { - Line::from(vec![ - "Press ".into(), - key_hint::plain(KeyCode::Enter).into(), - " to select or ".into(), - key_hint::plain(KeyCode::Esc).into(), - " to close".into(), - ]) -} - -fn skill_display_name(skill: &SkillMetadata) -> &str { - skill - .interface - .as_ref() - .and_then(|interface| interface.display_name.as_deref()) - .unwrap_or(&skill.name) -} - -fn skill_description(skill: &SkillMetadata) -> &str { - skill - .interface - .as_ref() - .and_then(|interface| interface.short_description.as_deref()) - .or(skill.short_description.as_deref()) - .unwrap_or(&skill.description) -} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__backspace_after_pastes.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__backspace_after_pastes.snap deleted file mode 100644 index 00821b7910d..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__backspace_after_pastes.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tui2/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› [Pasted Content 1002 chars][Pasted Content 1004 chars] " -" " -" " -" " -" " -" " -" " -" " -" 100% context left " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__empty.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__empty.snap deleted file mode 100644 index 1a34b29f924..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__empty.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tui2/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› Ask Codex to do anything " -" " -" " -" " -" " -" " -" " -" " -" 100% context left · ? for shortcuts " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap deleted file mode 100644 index d9395f2b055..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui2/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› Ask Codex to do anything " -" " -" " -" " -" " -" " -" " -" ctrl + c again to quit " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap deleted file mode 100644 index d9395f2b055..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui2/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› Ask Codex to do anything " -" " -" " -" " -" " -" " -" " -" ctrl + c again to quit " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap deleted file mode 100644 index 9e93b8d6833..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui2/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› Ask Codex to do anything " -" " -" " -" " -" " -" " -" " -" esc esc to edit previous message " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap deleted file mode 100644 index 1d16779b01f..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui2/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› Ask Codex to do anything " -" " -" " -" " -" " -" " -" " -" esc again to edit previous message " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap deleted file mode 100644 index 9e93b8d6833..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui2/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› Ask Codex to do anything " -" " -" " -" " -" " -" " -" " -" esc esc to edit previous message " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_hidden_while_typing.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_hidden_while_typing.snap deleted file mode 100644 index 0aa72ca002d..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_hidden_while_typing.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui2/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› h " -" " -" " -" " -" " -" " -" " -" 100% context left " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap deleted file mode 100644 index 1d16779b01f..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui2/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› Ask Codex to do anything " -" " -" " -" " -" " -" " -" " -" esc again to edit previous message " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap deleted file mode 100644 index be9e62f40c6..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap +++ /dev/null @@ -1,17 +0,0 @@ ---- -source: tui2/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› Ask Codex to do anything " -" " -" " -" " -" " -" " -" " -" / for commands ! for shell commands " -" shift + enter for newline tab to queue message " -" @ for file paths ctrl + v to paste images " -" esc again to edit previous message ctrl + c to exit " -" ctrl + t to view transcript " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__large.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__large.snap deleted file mode 100644 index 3b7711d75f0..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__large.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tui2/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› [Pasted Content 1005 chars] " -" " -" " -" " -" " -" " -" " -" " -" 100% context left " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__multiple_pastes.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__multiple_pastes.snap deleted file mode 100644 index 426afbec6ec..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__multiple_pastes.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tui2/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› [Pasted Content 1003 chars][Pasted Content 1007 chars] another short paste " -" " -" " -" " -" " -" " -" " -" " -" 100% context left " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__slash_popup_mo.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__slash_popup_mo.snap deleted file mode 100644 index dc66d149e47..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__slash_popup_mo.snap +++ /dev/null @@ -1,9 +0,0 @@ ---- -source: tui2/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› /mo " -" " -" /model choose what model and reasoning effort to use " -" /mention mention a file " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__slash_popup_res.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__slash_popup_res.snap deleted file mode 100644 index daedb3d8889..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__slash_popup_res.snap +++ /dev/null @@ -1,10 +0,0 @@ ---- -source: tui2/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› /res " -" " -" " -" " -" /resume resume a saved chat " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__small.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__small.snap deleted file mode 100644 index 8f669e1cb93..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__small.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tui2/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› short " -" " -" " -" " -" " -" " -" " -" " -" 100% context left " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_bad_result.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_bad_result.snap deleted file mode 100644 index f3c3a319bcd..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_bad_result.snap +++ /dev/null @@ -1,9 +0,0 @@ ---- -source: tui2/src/bottom_pane/feedback_view.rs -expression: rendered ---- -▌ Tell us more (bad result) -▌ -▌ (optional) Write a short description to help us further - -Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_bug.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_bug.snap deleted file mode 100644 index 2ab262c229e..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_bug.snap +++ /dev/null @@ -1,9 +0,0 @@ ---- -source: tui2/src/bottom_pane/feedback_view.rs -expression: rendered ---- -▌ Tell us more (bug) -▌ -▌ (optional) Write a short description to help us further - -Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_good_result.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_good_result.snap deleted file mode 100644 index 6bd68462029..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_good_result.snap +++ /dev/null @@ -1,9 +0,0 @@ ---- -source: tui2/src/bottom_pane/feedback_view.rs -expression: rendered ---- -▌ Tell us more (good result) -▌ -▌ (optional) Write a short description to help us further - -Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_other.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_other.snap deleted file mode 100644 index 1ec33c54eee..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_other.snap +++ /dev/null @@ -1,9 +0,0 @@ ---- -source: tui2/src/bottom_pane/feedback_view.rs -expression: rendered ---- -▌ Tell us more (other) -▌ -▌ (optional) Write a short description to help us further - -Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_context_only_queue_hint_disabled.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_context_only_queue_hint_disabled.snap deleted file mode 100644 index 09d51329ad9..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_context_only_queue_hint_disabled.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui2/src/bottom_pane/footer.rs -expression: terminal.backend() ---- -" 100% context left " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_context_only_queue_hint_enabled.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_context_only_queue_hint_enabled.snap deleted file mode 100644 index 6f985f20cca..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_context_only_queue_hint_enabled.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui2/src/bottom_pane/footer.rs -expression: terminal.backend() ---- -" 100% context left · tab to queue message " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_context_tokens_used.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_context_tokens_used.snap deleted file mode 100644 index e31cf10f068..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_context_tokens_used.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui2/src/bottom_pane/footer.rs -expression: terminal.backend() ---- -" 123K used · ? for shortcuts " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_copy_feedback_copied.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_copy_feedback_copied.snap deleted file mode 100644 index 77a9306adc7..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_copy_feedback_copied.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: tui2/src/bottom_pane/footer.rs -assertion_line: 473 -expression: terminal.backend() ---- -" 100% context left · ? for shortcuts · Copied " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap deleted file mode 100644 index 157853e73d5..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui2/src/bottom_pane/footer.rs -expression: terminal.backend() ---- -" ctrl + c again to quit " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap deleted file mode 100644 index 157853e73d5..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui2/src/bottom_pane/footer.rs -expression: terminal.backend() ---- -" ctrl + c again to quit " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_esc_hint_idle.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_esc_hint_idle.snap deleted file mode 100644 index 201bec4f629..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_esc_hint_idle.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui2/src/bottom_pane/footer.rs -expression: terminal.backend() ---- -" esc esc to edit previous message " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_esc_hint_primed.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_esc_hint_primed.snap deleted file mode 100644 index 0bc46a989af..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_esc_hint_primed.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui2/src/bottom_pane/footer.rs -expression: terminal.backend() ---- -" esc again to edit previous message " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap deleted file mode 100644 index 78af40d9f33..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap +++ /dev/null @@ -1,10 +0,0 @@ ---- -source: tui2/src/bottom_pane/footer.rs -expression: terminal.backend() ---- -" / for commands ! for shell commands " -" shift + enter for newline tab to queue message " -" @ for file paths ctrl + v to paste images " -" esc again to edit previous message ctrl + c to exit " -" shift + tab to change mode " -" ctrl + t to view transcript " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_context_running.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_context_running.snap deleted file mode 100644 index 2dd8738fe0c..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_context_running.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui2/src/bottom_pane/footer.rs -expression: terminal.backend() ---- -" 72% context left · ? for shortcuts " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_default.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_default.snap deleted file mode 100644 index 286acadd8b9..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_default.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui2/src/bottom_pane/footer.rs -expression: terminal.backend() ---- -" 100% context left · ? for shortcuts " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap deleted file mode 100644 index ba860d1f488..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap +++ /dev/null @@ -1,9 +0,0 @@ ---- -source: tui2/src/bottom_pane/footer.rs -expression: terminal.backend() ---- -" / for commands ! for shell commands " -" shift + enter for newline tab to queue message " -" @ for file paths ctrl + v to paste images " -" esc again to edit previous message ctrl + c to exit " -" ctrl + t to view transcript " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_transcript_scrolled_and_selection.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_transcript_scrolled_and_selection.snap deleted file mode 100644 index ee592efa263..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_transcript_scrolled_and_selection.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui2/src/bottom_pane/footer.rs -expression: terminal.backend() ---- -" 100% context left · ? for shortcuts · pgup/pgdn scroll · home/end jump · 3/42 " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_footer_note_wraps.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_footer_note_wraps.snap deleted file mode 100644 index 18d0eb2483b..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_footer_note_wraps.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tui2/src/bottom_pane/list_selection_view.rs -assertion_line: 640 -expression: "render_lines_with_width(&view, 40)" ---- - - Select Approval Mode - -› 1. Read Only (current) Codex can - read files - - Note: Use /setup-elevated-sandbox to - allow network access. - Press enter to confirm or esc to go ba diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_model_picker_width_80.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_model_picker_width_80.snap deleted file mode 100644 index b46a229ad4c..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_model_picker_width_80.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui2/src/bottom_pane/list_selection_view.rs -expression: "render_lines_with_width(&view, 80)" ---- - - Select Model and Effort - -› 1. gpt-5.1-codex (current) Optimized for Codex. Balance of reasoning - quality and coding ability. - 2. gpt-5.1-codex-mini Optimized for Codex. Cheaper, faster, but less - capable. - 3. gpt-4.1-codex Legacy model. Use when you need compatibility - with older automations. diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_narrow_width_preserves_rows.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_narrow_width_preserves_rows.snap deleted file mode 100644 index bcdc8a35615..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_narrow_width_preserves_rows.snap +++ /dev/null @@ -1,16 +0,0 @@ ---- -source: tui2/src/bottom_pane/list_selection_view.rs -expression: "render_lines_with_width(&view, 24)" ---- - - Debug - -› 1. Item 1 - xxxxxxxxx - x - 2. Item 2 - xxxxxxxxx - x - 3. Item 3 - xxxxxxxxx - x diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap deleted file mode 100644 index 2cc2578c56a..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap +++ /dev/null @@ -1,12 +0,0 @@ ---- -source: tui2/src/bottom_pane/list_selection_view.rs -expression: render_lines(&view) ---- - - Select Approval Mode - Switch between Codex approval presets - -› 1. Read Only (current) Codex can read files - 2. Full Access Codex can edit files - - Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap deleted file mode 100644 index 88a5d14932f..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap +++ /dev/null @@ -1,11 +0,0 @@ ---- -source: tui2/src/bottom_pane/list_selection_view.rs -expression: render_lines(&view) ---- - - Select Approval Mode - -› 1. Read Only (current) Codex can read files - 2. Full Access Codex can edit files - - Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_many_line_message.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_many_line_message.snap deleted file mode 100644 index c715e81c9a9..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_many_line_message.snap +++ /dev/null @@ -1,27 +0,0 @@ ---- -source: tui2/src/bottom_pane/queued_user_messages.rs -expression: "format!(\"{buf:?}\")" ---- -Buffer { - area: Rect { x: 0, y: 0, width: 40, height: 5 }, - content: [ - " ↳ This is ", - " a message ", - " with many ", - " … ", - " ⌥ + ↑ edit ", - ], - styles: [ - x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 11, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 13, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 13, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 5, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 14, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - ] -} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_more_than_three_messages.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_more_than_three_messages.snap deleted file mode 100644 index 1e88bfb5b19..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_more_than_three_messages.snap +++ /dev/null @@ -1,30 +0,0 @@ ---- -source: tui2/src/bottom_pane/queued_user_messages.rs -expression: "format!(\"{buf:?}\")" ---- -Buffer { - area: Rect { x: 0, y: 0, width: 40, height: 5 }, - content: [ - " ↳ Hello, world! ", - " ↳ This is another message ", - " ↳ This is a third message ", - " ↳ This is a fourth message ", - " ⌥ + ↑ edit ", - ], - styles: [ - x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 17, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 27, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 27, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 28, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 14, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - ] -} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_one_message.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_one_message.snap deleted file mode 100644 index 8160a886db5..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_one_message.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -source: tui2/src/bottom_pane/queued_user_messages.rs -expression: "format!(\"{buf:?}\")" ---- -Buffer { - area: Rect { x: 0, y: 0, width: 40, height: 2 }, - content: [ - " ↳ Hello, world! ", - " ⌥ + ↑ edit ", - ], - styles: [ - x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 17, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 14, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - ] -} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_two_messages.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_two_messages.snap deleted file mode 100644 index 9b1ef9e5c60..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_two_messages.snap +++ /dev/null @@ -1,22 +0,0 @@ ---- -source: tui2/src/bottom_pane/queued_user_messages.rs -expression: "format!(\"{buf:?}\")" ---- -Buffer { - area: Rect { x: 0, y: 0, width: 40, height: 3 }, - content: [ - " ↳ Hello, world! ", - " ↳ This is another message ", - " ⌥ + ↑ edit ", - ], - styles: [ - x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 17, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 27, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 14, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - ] -} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_wrapped_message.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_wrapped_message.snap deleted file mode 100644 index f46cf990fa6..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_wrapped_message.snap +++ /dev/null @@ -1,25 +0,0 @@ ---- -source: tui2/src/bottom_pane/queued_user_messages.rs -expression: "format!(\"{buf:?}\")" ---- -Buffer { - area: Rect { x: 0, y: 0, width: 40, height: 4 }, - content: [ - " ↳ This is a longer message that should", - " be wrapped ", - " ↳ This is another message ", - " ⌥ + ↑ edit ", - ], - styles: [ - x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 14, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 27, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 14, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - ] -} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap deleted file mode 100644 index efc74d42bab..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap +++ /dev/null @@ -1,10 +0,0 @@ ---- -source: tui2/src/bottom_pane/mod.rs -expression: "render_snapshot(&pane, area)" ---- - ↳ Queued follow-up question - ⌥ + ↑ edit - -› Ask Codex to do anything - - 100% context left · ? for shortcuts diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap deleted file mode 100644 index f6c157922a3..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap +++ /dev/null @@ -1,10 +0,0 @@ ---- -source: tui2/src/bottom_pane/mod.rs -expression: "render_snapshot(&pane, area)" ---- -• Working (0s • esc to interru - - -› Ask Codex to do anything - - 100% context left · ? for sh diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_and_queued_messages_snapshot.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_and_queued_messages_snapshot.snap deleted file mode 100644 index cdea8a17d8a..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_and_queued_messages_snapshot.snap +++ /dev/null @@ -1,12 +0,0 @@ ---- -source: tui2/src/bottom_pane/mod.rs -expression: "render_snapshot(&pane, area)" ---- -• Working (0s • esc to interrupt) - - ↳ Queued follow-up question - ⌥ + ↑ edit - -› Ask Codex to do anything - - 100% context left · ? for shortcuts diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_only_snapshot.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_only_snapshot.snap deleted file mode 100644 index 3b53b4d8622..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_only_snapshot.snap +++ /dev/null @@ -1,10 +0,0 @@ ---- -source: tui2/src/bottom_pane/mod.rs -expression: "render_snapshot(&pane, area)" ---- -• Working (0s • esc to interrupt) - - -› Ask Codex to do anything - - 100% context left · ? for shortcuts diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_with_details_and_queued_messages_snapshot.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_with_details_and_queued_messages_snapshot.snap deleted file mode 100644 index 51236bb69b4..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_with_details_and_queued_messages_snapshot.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tui2/src/bottom_pane/mod.rs -expression: "render_snapshot(&pane, area)" ---- -• Working (0s • esc to interrupt) - └ First detail line - Second detail line - - ↳ Queued follow-up question - ⌥ + ↑ edit - -› Ask Codex to do anything - - 100% context left · ? for shortcuts diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap deleted file mode 100644 index e4cc9ffefd5..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› [Pasted Content 1002 chars][Pasted Content 1004 chars] " -" " -" " -" " -" " -" " -" " -" " -" 100% context left " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap deleted file mode 100644 index 53e0aee4cf9..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› Ask Codex to do anything " -" " -" " -" " -" " -" " -" " -" " -" 100% context left · ? for shortcuts " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap deleted file mode 100644 index 49ffb0d4c8f..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› Ask Codex to do anything " -" " -" " -" " -" " -" " -" " -" ctrl + c again to interrupt " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap deleted file mode 100644 index 7ecc5bba719..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› Ask Codex to do anything " -" " -" " -" " -" " -" " -" " -" ctrl + c again to quit " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap deleted file mode 100644 index 9cad17b8648..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› Ask Codex to do anything " -" " -" " -" " -" " -" " -" " -" esc esc to edit previous message " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap deleted file mode 100644 index 2fce42cc26b..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› Ask Codex to do anything " -" " -" " -" " -" " -" " -" " -" esc again to edit previous message " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap deleted file mode 100644 index 9cad17b8648..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› Ask Codex to do anything " -" " -" " -" " -" " -" " -" " -" esc esc to edit previous message " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_hidden_while_typing.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_hidden_while_typing.snap deleted file mode 100644 index 67e616e917f..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_hidden_while_typing.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› h " -" " -" " -" " -" " -" " -" " -" 100% context left " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap deleted file mode 100644 index 2fce42cc26b..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› Ask Codex to do anything " -" " -" " -" " -" " -" " -" " -" esc again to edit previous message " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap deleted file mode 100644 index 0272440027e..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap +++ /dev/null @@ -1,17 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› Ask Codex to do anything " -" " -" " -" " -" " -" " -" / for commands ! for shell commands " -" shift + enter for newline tab to queue message " -" @ for file paths ctrl + v to paste images " -" ctrl + g to edit in external editor esc again to edit previous message " -" ctrl + c to exit " -" ctrl + t to view transcript " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap deleted file mode 100644 index 6b018021ece..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› [Pasted Content 1005 chars] " -" " -" " -" " -" " -" " -" " -" " -" 100% context left " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap deleted file mode 100644 index 40098faee01..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› [Pasted Content 1003 chars][Pasted Content 1007 chars] another short paste " -" " -" " -" " -" " -" " -" " -" " -" 100% context left " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap deleted file mode 100644 index 661e82e3ad1..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap +++ /dev/null @@ -1,9 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› /mo " -" " -" /model choose what model and reasoning effort to use " -" /mention mention a file " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_res.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_res.snap deleted file mode 100644 index df8ea36e638..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_res.snap +++ /dev/null @@ -1,11 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer.rs -assertion_line: 2385 -expression: terminal.backend() ---- -" " -"› /res " -" " -" " -" " -" /resume resume a saved chat " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap deleted file mode 100644 index 498ed769366..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› short " -" " -" " -" " -" " -" " -" " -" " -" 100% context left " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_bad_result.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_bad_result.snap deleted file mode 100644 index 465f0f9c4f3..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_bad_result.snap +++ /dev/null @@ -1,9 +0,0 @@ ---- -source: tui/src/bottom_pane/feedback_view.rs -expression: rendered ---- -▌ Tell us more (bad result) -▌ -▌ (optional) Write a short description to help us further - -Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_bug.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_bug.snap deleted file mode 100644 index a0b5660135b..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_bug.snap +++ /dev/null @@ -1,9 +0,0 @@ ---- -source: tui/src/bottom_pane/feedback_view.rs -expression: rendered ---- -▌ Tell us more (bug) -▌ -▌ (optional) Write a short description to help us further - -Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_good_result.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_good_result.snap deleted file mode 100644 index 73074d61faa..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_good_result.snap +++ /dev/null @@ -1,9 +0,0 @@ ---- -source: tui/src/bottom_pane/feedback_view.rs -expression: rendered ---- -▌ Tell us more (good result) -▌ -▌ (optional) Write a short description to help us further - -Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_other.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_other.snap deleted file mode 100644 index 80e4ffeffe1..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_other.snap +++ /dev/null @@ -1,9 +0,0 @@ ---- -source: tui/src/bottom_pane/feedback_view.rs -expression: rendered ---- -▌ Tell us more (other) -▌ -▌ (optional) Write a short description to help us further - -Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_render.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_render.snap deleted file mode 100644 index bafa94b09de..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_render.snap +++ /dev/null @@ -1,17 +0,0 @@ ---- -source: tui/src/bottom_pane/feedback_view.rs -expression: rendered ---- - Do you want to upload logs before reporting issue? - - Logs may include the full conversation history of this Codex process - These logs are retained for 90 days and are used solely for troubles - - You can review the exact content of the logs before they’re uploaded - - - -› 1. Yes Share the current Codex session logs with the team for - troubleshooting. - 2. No - 3. Cancel diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_context_tokens_used.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_context_tokens_used.snap deleted file mode 100644 index a77ca5565b6..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_context_tokens_used.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui/src/bottom_pane/footer.rs -expression: terminal.backend() ---- -" 123K used · ? for shortcuts " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap deleted file mode 100644 index 31a1b743b8e..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui/src/bottom_pane/footer.rs -expression: terminal.backend() ---- -" ctrl + c again to quit " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap deleted file mode 100644 index 9979372a1b9..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui/src/bottom_pane/footer.rs -expression: terminal.backend() ---- -" ctrl + c again to interrupt " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_idle.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_idle.snap deleted file mode 100644 index b2333b025f6..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_idle.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui/src/bottom_pane/footer.rs -expression: terminal.backend() ---- -" esc esc to edit previous message " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_primed.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_primed.snap deleted file mode 100644 index 20f9b178b4b..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_primed.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui/src/bottom_pane/footer.rs -expression: terminal.backend() ---- -" esc again to edit previous message " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_context_running.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_context_running.snap deleted file mode 100644 index d05ac90a911..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_context_running.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui/src/bottom_pane/footer.rs -expression: terminal.backend() ---- -" 72% context left · ? for shortcuts " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap deleted file mode 100644 index c95a5dc0b3d..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui/src/bottom_pane/footer.rs -expression: terminal.backend() ---- -" 100% context left · ? for shortcuts " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap deleted file mode 100644 index c1f00d44377..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap +++ /dev/null @@ -1,10 +0,0 @@ ---- -source: tui/src/bottom_pane/footer.rs -expression: terminal.backend() ---- -" / for commands ! for shell commands " -" shift + enter for newline tab to queue message " -" @ for file paths ctrl + v to paste images " -" ctrl + g to edit in external editor esc again to edit previous message " -" ctrl + c to exit " -" ctrl + t to view transcript " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_model_picker_width_80.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_model_picker_width_80.snap deleted file mode 100644 index be81978c896..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_model_picker_width_80.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui/src/bottom_pane/list_selection_view.rs -expression: "render_lines_with_width(&view, 80)" ---- - - Select Model and Effort - -› 1. gpt-5.1-codex (current) Optimized for Codex. Balance of reasoning - quality and coding ability. - 2. gpt-5.1-codex-mini Optimized for Codex. Cheaper, faster, but less - capable. - 3. gpt-4.1-codex Legacy model. Use when you need compatibility - with older automations. diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_narrow_width_preserves_rows.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_narrow_width_preserves_rows.snap deleted file mode 100644 index 3ce6a3c45ff..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_narrow_width_preserves_rows.snap +++ /dev/null @@ -1,16 +0,0 @@ ---- -source: tui/src/bottom_pane/list_selection_view.rs -expression: "render_lines_with_width(&view, 24)" ---- - - Debug - -› 1. Item 1 - xxxxxxxxx - x - 2. Item 2 - xxxxxxxxx - x - 3. Item 3 - xxxxxxxxx - x diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap deleted file mode 100644 index 512f6bbca63..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap +++ /dev/null @@ -1,12 +0,0 @@ ---- -source: tui/src/bottom_pane/list_selection_view.rs -expression: render_lines(&view) ---- - - Select Approval Mode - Switch between Codex approval presets - -› 1. Read Only (current) Codex can read files - 2. Full Access Codex can edit files - - Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap deleted file mode 100644 index ddd0f90cd87..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap +++ /dev/null @@ -1,11 +0,0 @@ ---- -source: tui/src/bottom_pane/list_selection_view.rs -expression: render_lines(&view) ---- - - Select Approval Mode - -› 1. Read Only (current) Codex can read files - 2. Full Access Codex can edit files - - Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_many_line_message.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_many_line_message.snap deleted file mode 100644 index cf1f7248b32..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_many_line_message.snap +++ /dev/null @@ -1,27 +0,0 @@ ---- -source: tui/src/bottom_pane/message_queue.rs -expression: "format!(\"{buf:?}\")" ---- -Buffer { - area: Rect { x: 0, y: 0, width: 40, height: 5 }, - content: [ - " ↳ This is ", - " a message ", - " with many ", - " … ", - " alt + ↑ edit ", - ], - styles: [ - x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 11, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 13, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 13, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 5, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 16, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - ] -} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_one_message.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_one_message.snap deleted file mode 100644 index 5e403e1bddf..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_one_message.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -source: tui/src/bottom_pane/message_queue.rs -expression: "format!(\"{buf:?}\")" ---- -Buffer { - area: Rect { x: 0, y: 0, width: 40, height: 2 }, - content: [ - " ↳ Hello, world! ", - " alt + ↑ edit ", - ], - styles: [ - x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 17, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 16, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - ] -} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_two_messages.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_two_messages.snap deleted file mode 100644 index 4484509695b..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_two_messages.snap +++ /dev/null @@ -1,22 +0,0 @@ ---- -source: tui/src/bottom_pane/message_queue.rs -expression: "format!(\"{buf:?}\")" ---- -Buffer { - area: Rect { x: 0, y: 0, width: 40, height: 3 }, - content: [ - " ↳ Hello, world! ", - " ↳ This is another message ", - " alt + ↑ edit ", - ], - styles: [ - x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 17, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 27, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 16, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - ] -} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_wrapped_message.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_wrapped_message.snap deleted file mode 100644 index 16d63612574..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_wrapped_message.snap +++ /dev/null @@ -1,25 +0,0 @@ ---- -source: tui/src/bottom_pane/message_queue.rs -expression: "format!(\"{buf:?}\")" ---- -Buffer { - area: Rect { x: 0, y: 0, width: 40, height: 4 }, - content: [ - " ↳ This is a longer message that should", - " be wrapped ", - " ↳ This is another message ", - " alt + ↑ edit ", - ], - styles: [ - x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 14, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 27, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 16, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - ] -} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_many_line_message.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_many_line_message.snap deleted file mode 100644 index d2afbf7dbd0..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_many_line_message.snap +++ /dev/null @@ -1,27 +0,0 @@ ---- -source: tui/src/bottom_pane/queued_user_messages.rs -expression: "format!(\"{buf:?}\")" ---- -Buffer { - area: Rect { x: 0, y: 0, width: 40, height: 5 }, - content: [ - " ↳ This is ", - " a message ", - " with many ", - " … ", - " ⌥ + ↑ edit ", - ], - styles: [ - x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 11, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 13, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 13, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 5, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 14, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - ] -} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_more_than_three_messages.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_more_than_three_messages.snap deleted file mode 100644 index 9d7527d16fa..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_more_than_three_messages.snap +++ /dev/null @@ -1,30 +0,0 @@ ---- -source: tui/src/bottom_pane/queued_user_messages.rs -expression: "format!(\"{buf:?}\")" ---- -Buffer { - area: Rect { x: 0, y: 0, width: 40, height: 5 }, - content: [ - " ↳ Hello, world! ", - " ↳ This is another message ", - " ↳ This is a third message ", - " ↳ This is a fourth message ", - " ⌥ + ↑ edit ", - ], - styles: [ - x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 17, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 27, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 27, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 28, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 14, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - ] -} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_one_message.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_one_message.snap deleted file mode 100644 index d47fa978634..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_one_message.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -source: tui/src/bottom_pane/queued_user_messages.rs -expression: "format!(\"{buf:?}\")" ---- -Buffer { - area: Rect { x: 0, y: 0, width: 40, height: 2 }, - content: [ - " ↳ Hello, world! ", - " ⌥ + ↑ edit ", - ], - styles: [ - x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 17, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 14, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - ] -} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_two_messages.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_two_messages.snap deleted file mode 100644 index 1f020fec64e..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_two_messages.snap +++ /dev/null @@ -1,22 +0,0 @@ ---- -source: tui/src/bottom_pane/queued_user_messages.rs -expression: "format!(\"{buf:?}\")" ---- -Buffer { - area: Rect { x: 0, y: 0, width: 40, height: 3 }, - content: [ - " ↳ Hello, world! ", - " ↳ This is another message ", - " ⌥ + ↑ edit ", - ], - styles: [ - x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 17, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 27, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 14, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - ] -} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_wrapped_message.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_wrapped_message.snap deleted file mode 100644 index 4f2917a6c42..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_wrapped_message.snap +++ /dev/null @@ -1,25 +0,0 @@ ---- -source: tui/src/bottom_pane/queued_user_messages.rs -expression: "format!(\"{buf:?}\")" ---- -Buffer { - area: Rect { x: 0, y: 0, width: 40, height: 4 }, - content: [ - " ↳ This is a longer message that should", - " be wrapped ", - " ↳ This is another message ", - " ⌥ + ↑ edit ", - ], - styles: [ - x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 14, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 27, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 14, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - ] -} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap deleted file mode 100644 index 123a5eb3a3e..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap +++ /dev/null @@ -1,11 +0,0 @@ ---- -source: tui/src/bottom_pane/mod.rs -expression: "render_snapshot(&pane, area)" ---- - ↳ Queued follow-up question - ⌥ + ↑ edit - - -› Ask Codex to do anything - - 100% context left · ? for shortcuts diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap deleted file mode 100644 index 86e3da45730..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap +++ /dev/null @@ -1,10 +0,0 @@ ---- -source: tui/src/bottom_pane/mod.rs -expression: "render_snapshot(&pane, area)" ---- -• Working (0s • esc to interru - - -› Ask Codex to do anything - - 100% context left · ? for sh diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap deleted file mode 100644 index 27df671e4d3..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap +++ /dev/null @@ -1,12 +0,0 @@ ---- -source: tui/src/bottom_pane/mod.rs -expression: "render_snapshot(&pane, area)" ---- -• Working (0s • esc to interrupt) - ↳ Queued follow-up question - ⌥ + ↑ edit - - -› Ask Codex to do anything - - 100% context left · ? for shortcuts diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_hidden_when_height_too_small_height_1.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_hidden_when_height_too_small_height_1.snap deleted file mode 100644 index 52f96e8557a..00000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_hidden_when_height_too_small_height_1.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui/src/bottom_pane/mod.rs -expression: "render_snapshot(&pane, area1)" ---- -› Ask Codex to do a diff --git a/codex-rs/tui2/src/bottom_pane/textarea.rs b/codex-rs/tui2/src/bottom_pane/textarea.rs deleted file mode 100644 index 7c39379c9f7..00000000000 --- a/codex-rs/tui2/src/bottom_pane/textarea.rs +++ /dev/null @@ -1,2180 +0,0 @@ -use crate::key_hint::is_altgr; -use codex_protocol::user_input::ByteRange; -use codex_protocol::user_input::TextElement as UserTextElement; -use crossterm::event::KeyCode; -use crossterm::event::KeyEvent; -use crossterm::event::KeyModifiers; -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -use ratatui::style::Color; -use ratatui::style::Style; -use ratatui::widgets::StatefulWidgetRef; -use ratatui::widgets::WidgetRef; -use std::cell::Ref; -use std::cell::RefCell; -use std::ops::Range; -use textwrap::Options; -use unicode_segmentation::UnicodeSegmentation; -use unicode_width::UnicodeWidthStr; - -const WORD_SEPARATORS: &str = "`~!@#$%^&*()-=+[{]}\\|;:'\",.<>/?"; - -fn is_word_separator(ch: char) -> bool { - WORD_SEPARATORS.contains(ch) -} - -#[derive(Debug, Clone)] -struct TextElement { - range: Range, -} - -#[derive(Debug)] -pub(crate) struct TextArea { - text: String, - cursor_pos: usize, - wrap_cache: RefCell>, - preferred_col: Option, - elements: Vec, - kill_buffer: String, -} - -#[derive(Debug, Clone)] -struct WrapCache { - width: u16, - lines: Vec>, -} - -#[derive(Debug, Default, Clone, Copy)] -pub(crate) struct TextAreaState { - /// Index into wrapped lines of the first visible line. - scroll: u16, -} - -impl TextArea { - pub fn new() -> Self { - Self { - text: String::new(), - cursor_pos: 0, - wrap_cache: RefCell::new(None), - preferred_col: None, - elements: Vec::new(), - kill_buffer: String::new(), - } - } - - /// Replace the textarea text and clear any existing text elements. - pub fn set_text_clearing_elements(&mut self, text: &str) { - self.set_text_inner(text, None); - } - - /// Replace the textarea text and set the provided text elements. - pub fn set_text_with_elements(&mut self, text: &str, elements: &[UserTextElement]) { - self.set_text_inner(text, Some(elements)); - } - - fn set_text_inner(&mut self, text: &str, elements: Option<&[UserTextElement]>) { - self.text = text.to_string(); - self.cursor_pos = self.cursor_pos.clamp(0, self.text.len()); - self.elements.clear(); - if let Some(elements) = elements { - for elem in elements { - let mut start = elem.byte_range.start.min(self.text.len()); - let mut end = elem.byte_range.end.min(self.text.len()); - start = self.clamp_pos_to_char_boundary(start); - end = self.clamp_pos_to_char_boundary(end); - if start >= end { - continue; - } - self.elements.push(TextElement { range: start..end }); - } - self.elements.sort_by_key(|e| e.range.start); - } - self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos); - self.wrap_cache.replace(None); - self.preferred_col = None; - self.kill_buffer.clear(); - } - - pub fn text(&self) -> &str { - &self.text - } - - pub fn insert_str(&mut self, text: &str) { - self.insert_str_at(self.cursor_pos, text); - } - - pub fn insert_str_at(&mut self, pos: usize, text: &str) { - let pos = self.clamp_pos_for_insertion(pos); - self.text.insert_str(pos, text); - self.wrap_cache.replace(None); - if pos <= self.cursor_pos { - self.cursor_pos += text.len(); - } - self.shift_elements(pos, 0, text.len()); - self.preferred_col = None; - } - - pub fn replace_range(&mut self, range: std::ops::Range, text: &str) { - let range = self.expand_range_to_element_boundaries(range); - self.replace_range_raw(range, text); - } - - fn replace_range_raw(&mut self, range: std::ops::Range, text: &str) { - assert!(range.start <= range.end); - let start = range.start.clamp(0, self.text.len()); - let end = range.end.clamp(0, self.text.len()); - let removed_len = end - start; - let inserted_len = text.len(); - if removed_len == 0 && inserted_len == 0 { - return; - } - let diff = inserted_len as isize - removed_len as isize; - - self.text.replace_range(range, text); - self.wrap_cache.replace(None); - self.preferred_col = None; - self.update_elements_after_replace(start, end, inserted_len); - - // Update the cursor position to account for the edit. - self.cursor_pos = if self.cursor_pos < start { - // Cursor was before the edited range – no shift. - self.cursor_pos - } else if self.cursor_pos <= end { - // Cursor was inside the replaced range – move to end of the new text. - start + inserted_len - } else { - // Cursor was after the replaced range – shift by the length diff. - ((self.cursor_pos as isize) + diff) as usize - } - .min(self.text.len()); - - // Ensure cursor is not inside an element - self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos); - } - - pub fn cursor(&self) -> usize { - self.cursor_pos - } - - pub fn set_cursor(&mut self, pos: usize) { - self.cursor_pos = pos.clamp(0, self.text.len()); - self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos); - self.preferred_col = None; - } - - pub fn desired_height(&self, width: u16) -> u16 { - self.wrapped_lines(width).len() as u16 - } - - #[cfg_attr(not(test), allow(dead_code))] - pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { - self.cursor_pos_with_state(area, TextAreaState::default()) - } - - /// Compute the on-screen cursor position taking scrolling into account. - pub fn cursor_pos_with_state(&self, area: Rect, state: TextAreaState) -> Option<(u16, u16)> { - let lines = self.wrapped_lines(area.width); - let effective_scroll = self.effective_scroll(area.height, &lines, state.scroll); - let i = Self::wrapped_line_index_by_start(&lines, self.cursor_pos)?; - let ls = &lines[i]; - let col = self.text[ls.start..self.cursor_pos].width() as u16; - let screen_row = i - .saturating_sub(effective_scroll as usize) - .try_into() - .unwrap_or(0); - Some((area.x + col, area.y + screen_row)) - } - - pub fn is_empty(&self) -> bool { - self.text.is_empty() - } - - fn current_display_col(&self) -> usize { - let bol = self.beginning_of_current_line(); - self.text[bol..self.cursor_pos].width() - } - - fn wrapped_line_index_by_start(lines: &[Range], pos: usize) -> Option { - // partition_point returns the index of the first element for which - // the predicate is false, i.e. the count of elements with start <= pos. - let idx = lines.partition_point(|r| r.start <= pos); - if idx == 0 { None } else { Some(idx - 1) } - } - - fn move_to_display_col_on_line( - &mut self, - line_start: usize, - line_end: usize, - target_col: usize, - ) { - let mut width_so_far = 0usize; - for (i, g) in self.text[line_start..line_end].grapheme_indices(true) { - width_so_far += g.width(); - if width_so_far > target_col { - self.cursor_pos = line_start + i; - // Avoid landing inside an element; round to nearest boundary - self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos); - return; - } - } - self.cursor_pos = line_end; - self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos); - } - - fn beginning_of_line(&self, pos: usize) -> usize { - self.text[..pos].rfind('\n').map(|i| i + 1).unwrap_or(0) - } - fn beginning_of_current_line(&self) -> usize { - self.beginning_of_line(self.cursor_pos) - } - - fn end_of_line(&self, pos: usize) -> usize { - self.text[pos..] - .find('\n') - .map(|i| i + pos) - .unwrap_or(self.text.len()) - } - fn end_of_current_line(&self) -> usize { - self.end_of_line(self.cursor_pos) - } - - pub fn input(&mut self, event: KeyEvent) { - match event { - // Some terminals (or configurations) send Control key chords as - // C0 control characters without reporting the CONTROL modifier. - // Handle common fallbacks for Ctrl-B/F/P/N here so they don't get - // inserted as literal control bytes. - KeyEvent { code: KeyCode::Char('\u{0002}'), modifiers: KeyModifiers::NONE, .. } /* ^B */ => { - self.move_cursor_left(); - } - KeyEvent { code: KeyCode::Char('\u{0006}'), modifiers: KeyModifiers::NONE, .. } /* ^F */ => { - self.move_cursor_right(); - } - KeyEvent { code: KeyCode::Char('\u{0010}'), modifiers: KeyModifiers::NONE, .. } /* ^P */ => { - self.move_cursor_up(); - } - KeyEvent { code: KeyCode::Char('\u{000e}'), modifiers: KeyModifiers::NONE, .. } /* ^N */ => { - self.move_cursor_down(); - } - KeyEvent { - code: KeyCode::Char(c), - // Insert plain characters (and Shift-modified). Do NOT insert when ALT is held, - // because many terminals map Option/Meta combos to ALT+ (e.g. ESC f/ESC b) - // for word navigation. Those are handled explicitly below. - modifiers: KeyModifiers::NONE | KeyModifiers::SHIFT, - .. - } => self.insert_str(&c.to_string()), - KeyEvent { - code: KeyCode::Char('j' | 'm'), - modifiers: KeyModifiers::CONTROL, - .. - } - | KeyEvent { - code: KeyCode::Enter, - .. - } => self.insert_str("\n"), - KeyEvent { - code: KeyCode::Char('h'), - modifiers, - .. - } if modifiers == (KeyModifiers::CONTROL | KeyModifiers::ALT) => { - self.delete_backward_word() - }, - // Windows AltGr generates ALT|CONTROL; treat as a plain character input unless - // we match a specific Control+Alt binding above. - KeyEvent { - code: KeyCode::Char(c), - modifiers, - .. - } if is_altgr(modifiers) => self.insert_str(&c.to_string()), - KeyEvent { - code: KeyCode::Backspace, - modifiers: KeyModifiers::ALT, - .. - } => self.delete_backward_word(), - KeyEvent { - code: KeyCode::Backspace, - .. - } - | KeyEvent { - code: KeyCode::Char('h'), - modifiers: KeyModifiers::CONTROL, - .. - } => self.delete_backward(1), - KeyEvent { - code: KeyCode::Delete, - modifiers: KeyModifiers::ALT, - .. - } => self.delete_forward_word(), - KeyEvent { - code: KeyCode::Delete, - .. - } - | KeyEvent { - code: KeyCode::Char('d'), - modifiers: KeyModifiers::CONTROL, - .. - } => self.delete_forward(1), - - KeyEvent { - code: KeyCode::Char('w'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - self.delete_backward_word(); - } - // Meta-b -> move to beginning of previous word - // Meta-f -> move to end of next word - // Many terminals map Option (macOS) to Alt. Some send Alt|Shift, so match contains(ALT). - KeyEvent { - code: KeyCode::Char('b'), - modifiers: KeyModifiers::ALT, - .. - } => { - self.set_cursor(self.beginning_of_previous_word()); - } - KeyEvent { - code: KeyCode::Char('f'), - modifiers: KeyModifiers::ALT, - .. - } => { - self.set_cursor(self.end_of_next_word()); - } - KeyEvent { - code: KeyCode::Char('u'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - self.kill_to_beginning_of_line(); - } - KeyEvent { - code: KeyCode::Char('k'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - self.kill_to_end_of_line(); - } - KeyEvent { - code: KeyCode::Char('y'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - self.yank(); - } - - // Cursor movement - KeyEvent { - code: KeyCode::Left, - modifiers: KeyModifiers::NONE, - .. - } => { - self.move_cursor_left(); - } - KeyEvent { - code: KeyCode::Right, - modifiers: KeyModifiers::NONE, - .. - } => { - self.move_cursor_right(); - } - KeyEvent { - code: KeyCode::Char('b'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - self.move_cursor_left(); - } - KeyEvent { - code: KeyCode::Char('f'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - self.move_cursor_right(); - } - KeyEvent { - code: KeyCode::Char('p'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - self.move_cursor_up(); - } - KeyEvent { - code: KeyCode::Char('n'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - self.move_cursor_down(); - } - // Some terminals send Alt+Arrow for word-wise movement: - // Option/Left -> Alt+Left (previous word start) - // Option/Right -> Alt+Right (next word end) - KeyEvent { - code: KeyCode::Left, - modifiers: KeyModifiers::ALT, - .. - } - | KeyEvent { - code: KeyCode::Left, - modifiers: KeyModifiers::CONTROL, - .. - } => { - self.set_cursor(self.beginning_of_previous_word()); - } - KeyEvent { - code: KeyCode::Right, - modifiers: KeyModifiers::ALT, - .. - } - | KeyEvent { - code: KeyCode::Right, - modifiers: KeyModifiers::CONTROL, - .. - } => { - self.set_cursor(self.end_of_next_word()); - } - KeyEvent { - code: KeyCode::Up, .. - } => { - self.move_cursor_up(); - } - KeyEvent { - code: KeyCode::Down, - .. - } => { - self.move_cursor_down(); - } - KeyEvent { - code: KeyCode::Home, - .. - } => { - self.move_cursor_to_beginning_of_line(false); - } - KeyEvent { - code: KeyCode::Char('a'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - self.move_cursor_to_beginning_of_line(true); - } - - KeyEvent { - code: KeyCode::End, .. - } => { - self.move_cursor_to_end_of_line(false); - } - KeyEvent { - code: KeyCode::Char('e'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - self.move_cursor_to_end_of_line(true); - } - _o => { - #[cfg(feature = "debug-logs")] - tracing::debug!("Unhandled key event in TextArea: {:?}", _o); - } - } - } - - // ####### Input Functions ####### - pub fn delete_backward(&mut self, n: usize) { - if n == 0 || self.cursor_pos == 0 { - return; - } - let mut target = self.cursor_pos; - for _ in 0..n { - target = self.prev_atomic_boundary(target); - if target == 0 { - break; - } - } - self.replace_range(target..self.cursor_pos, ""); - } - - pub fn delete_forward(&mut self, n: usize) { - if n == 0 || self.cursor_pos >= self.text.len() { - return; - } - let mut target = self.cursor_pos; - for _ in 0..n { - target = self.next_atomic_boundary(target); - if target >= self.text.len() { - break; - } - } - self.replace_range(self.cursor_pos..target, ""); - } - - pub fn delete_backward_word(&mut self) { - let start = self.beginning_of_previous_word(); - self.kill_range(start..self.cursor_pos); - } - - /// Delete text to the right of the cursor using "word" semantics. - /// - /// Deletes from the current cursor position through the end of the next word as determined - /// by `end_of_next_word()`. Any whitespace (including newlines) between the cursor and that - /// word is included in the deletion. - pub fn delete_forward_word(&mut self) { - let end = self.end_of_next_word(); - if end > self.cursor_pos { - self.kill_range(self.cursor_pos..end); - } - } - - pub fn kill_to_end_of_line(&mut self) { - let eol = self.end_of_current_line(); - let range = if self.cursor_pos == eol { - if eol < self.text.len() { - Some(self.cursor_pos..eol + 1) - } else { - None - } - } else { - Some(self.cursor_pos..eol) - }; - - if let Some(range) = range { - self.kill_range(range); - } - } - - pub fn kill_to_beginning_of_line(&mut self) { - let bol = self.beginning_of_current_line(); - let range = if self.cursor_pos == bol { - if bol > 0 { Some(bol - 1..bol) } else { None } - } else { - Some(bol..self.cursor_pos) - }; - - if let Some(range) = range { - self.kill_range(range); - } - } - - pub fn yank(&mut self) { - if self.kill_buffer.is_empty() { - return; - } - let text = self.kill_buffer.clone(); - self.insert_str(&text); - } - - fn kill_range(&mut self, range: Range) { - let range = self.expand_range_to_element_boundaries(range); - if range.start >= range.end { - return; - } - - let removed = self.text[range.clone()].to_string(); - if removed.is_empty() { - return; - } - - self.kill_buffer = removed; - self.replace_range_raw(range, ""); - } - - /// Move the cursor left by a single grapheme cluster. - pub fn move_cursor_left(&mut self) { - self.cursor_pos = self.prev_atomic_boundary(self.cursor_pos); - self.preferred_col = None; - } - - /// Move the cursor right by a single grapheme cluster. - pub fn move_cursor_right(&mut self) { - self.cursor_pos = self.next_atomic_boundary(self.cursor_pos); - self.preferred_col = None; - } - - pub fn move_cursor_up(&mut self) { - // If we have a wrapping cache, prefer navigating across wrapped (visual) lines. - if let Some((target_col, maybe_line)) = { - let cache_ref = self.wrap_cache.borrow(); - if let Some(cache) = cache_ref.as_ref() { - let lines = &cache.lines; - if let Some(idx) = Self::wrapped_line_index_by_start(lines, self.cursor_pos) { - let cur_range = &lines[idx]; - let target_col = self - .preferred_col - .unwrap_or_else(|| self.text[cur_range.start..self.cursor_pos].width()); - if idx > 0 { - let prev = &lines[idx - 1]; - let line_start = prev.start; - let line_end = prev.end.saturating_sub(1); - Some((target_col, Some((line_start, line_end)))) - } else { - Some((target_col, None)) - } - } else { - None - } - } else { - None - } - } { - // We had wrapping info. Apply movement accordingly. - match maybe_line { - Some((line_start, line_end)) => { - if self.preferred_col.is_none() { - self.preferred_col = Some(target_col); - } - self.move_to_display_col_on_line(line_start, line_end, target_col); - return; - } - None => { - // Already at first visual line -> move to start - self.cursor_pos = 0; - self.preferred_col = None; - return; - } - } - } - - // Fallback to logical line navigation if we don't have wrapping info yet. - if let Some(prev_nl) = self.text[..self.cursor_pos].rfind('\n') { - let target_col = match self.preferred_col { - Some(c) => c, - None => { - let c = self.current_display_col(); - self.preferred_col = Some(c); - c - } - }; - let prev_line_start = self.text[..prev_nl].rfind('\n').map(|i| i + 1).unwrap_or(0); - let prev_line_end = prev_nl; - self.move_to_display_col_on_line(prev_line_start, prev_line_end, target_col); - } else { - self.cursor_pos = 0; - self.preferred_col = None; - } - } - - pub fn move_cursor_down(&mut self) { - // If we have a wrapping cache, prefer navigating across wrapped (visual) lines. - if let Some((target_col, move_to_last)) = { - let cache_ref = self.wrap_cache.borrow(); - if let Some(cache) = cache_ref.as_ref() { - let lines = &cache.lines; - if let Some(idx) = Self::wrapped_line_index_by_start(lines, self.cursor_pos) { - let cur_range = &lines[idx]; - let target_col = self - .preferred_col - .unwrap_or_else(|| self.text[cur_range.start..self.cursor_pos].width()); - if idx + 1 < lines.len() { - let next = &lines[idx + 1]; - let line_start = next.start; - let line_end = next.end.saturating_sub(1); - Some((target_col, Some((line_start, line_end)))) - } else { - Some((target_col, None)) - } - } else { - None - } - } else { - None - } - } { - match move_to_last { - Some((line_start, line_end)) => { - if self.preferred_col.is_none() { - self.preferred_col = Some(target_col); - } - self.move_to_display_col_on_line(line_start, line_end, target_col); - return; - } - None => { - // Already on last visual line -> move to end - self.cursor_pos = self.text.len(); - self.preferred_col = None; - return; - } - } - } - - // Fallback to logical line navigation if we don't have wrapping info yet. - let target_col = match self.preferred_col { - Some(c) => c, - None => { - let c = self.current_display_col(); - self.preferred_col = Some(c); - c - } - }; - if let Some(next_nl) = self.text[self.cursor_pos..] - .find('\n') - .map(|i| i + self.cursor_pos) - { - let next_line_start = next_nl + 1; - let next_line_end = self.text[next_line_start..] - .find('\n') - .map(|i| i + next_line_start) - .unwrap_or(self.text.len()); - self.move_to_display_col_on_line(next_line_start, next_line_end, target_col); - } else { - self.cursor_pos = self.text.len(); - self.preferred_col = None; - } - } - - pub fn move_cursor_to_beginning_of_line(&mut self, move_up_at_bol: bool) { - let bol = self.beginning_of_current_line(); - if move_up_at_bol && self.cursor_pos == bol { - self.set_cursor(self.beginning_of_line(self.cursor_pos.saturating_sub(1))); - } else { - self.set_cursor(bol); - } - self.preferred_col = None; - } - - pub fn move_cursor_to_end_of_line(&mut self, move_down_at_eol: bool) { - let eol = self.end_of_current_line(); - if move_down_at_eol && self.cursor_pos == eol { - let next_pos = (self.cursor_pos.saturating_add(1)).min(self.text.len()); - self.set_cursor(self.end_of_line(next_pos)); - } else { - self.set_cursor(eol); - } - } - - // ===== Text elements support ===== - - pub fn element_payloads(&self) -> Vec { - self.elements - .iter() - .filter_map(|e| self.text.get(e.range.clone()).map(str::to_string)) - .collect() - } - - pub fn text_elements(&self) -> Vec { - self.elements - .iter() - .map(|e| { - let placeholder = self.text.get(e.range.clone()).map(str::to_string); - UserTextElement::new( - ByteRange { - start: e.range.start, - end: e.range.end, - }, - placeholder, - ) - }) - .collect() - } - - pub fn element_payload_starting_at(&self, pos: usize) -> Option { - let pos = pos.min(self.text.len()); - let elem = self.elements.iter().find(|e| e.range.start == pos)?; - self.text.get(elem.range.clone()).map(str::to_string) - } - - /// Renames a single text element in-place, keeping it atomic. - /// - /// Use this when the element payload is an identifier (e.g. a placeholder) that must be - /// updated without converting the element back into normal text. - pub fn replace_element_payload(&mut self, old: &str, new: &str) -> bool { - let Some(idx) = self - .elements - .iter() - .position(|e| self.text.get(e.range.clone()) == Some(old)) - else { - return false; - }; - - let range = self.elements[idx].range.clone(); - let start = range.start; - let end = range.end; - if start > end || end > self.text.len() { - return false; - } - - let removed_len = end - start; - let inserted_len = new.len(); - let diff = inserted_len as isize - removed_len as isize; - - self.text.replace_range(range, new); - self.wrap_cache.replace(None); - self.preferred_col = None; - - // Update the modified element's range. - self.elements[idx].range = start..(start + inserted_len); - - // Shift element ranges that occur after the replaced element. - if diff != 0 { - for (j, e) in self.elements.iter_mut().enumerate() { - if j == idx { - continue; - } - if e.range.end <= start { - continue; - } - if e.range.start >= end { - e.range.start = ((e.range.start as isize) + diff) as usize; - e.range.end = ((e.range.end as isize) + diff) as usize; - continue; - } - - // Elements should not partially overlap each other; degrade gracefully by - // snapping anything intersecting the replaced range to the new bounds. - e.range.start = start.min(e.range.start); - e.range.end = (start + inserted_len).max(e.range.end.saturating_add_signed(diff)); - } - } - - // Update the cursor position to account for the edit. - self.cursor_pos = if self.cursor_pos < start { - self.cursor_pos - } else if self.cursor_pos <= end { - start + inserted_len - } else { - ((self.cursor_pos as isize) + diff) as usize - }; - self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos); - - // Keep element ordering deterministic. - self.elements.sort_by_key(|e| e.range.start); - - true - } - - pub fn insert_element(&mut self, text: &str) { - let start = self.clamp_pos_for_insertion(self.cursor_pos); - self.insert_str_at(start, text); - let end = start + text.len(); - self.add_element(start..end); - // Place cursor at end of inserted element - self.set_cursor(end); - } - - fn add_element(&mut self, range: Range) { - let elem = TextElement { range }; - self.elements.push(elem); - self.elements.sort_by_key(|e| e.range.start); - } - - fn find_element_containing(&self, pos: usize) -> Option { - self.elements - .iter() - .position(|e| pos > e.range.start && pos < e.range.end) - } - - fn clamp_pos_to_char_boundary(&self, pos: usize) -> usize { - let pos = pos.min(self.text.len()); - if self.text.is_char_boundary(pos) { - return pos; - } - let mut prev = pos; - while prev > 0 && !self.text.is_char_boundary(prev) { - prev -= 1; - } - let mut next = pos; - while next < self.text.len() && !self.text.is_char_boundary(next) { - next += 1; - } - if pos.saturating_sub(prev) <= next.saturating_sub(pos) { - prev - } else { - next - } - } - - fn clamp_pos_to_nearest_boundary(&self, pos: usize) -> usize { - let pos = self.clamp_pos_to_char_boundary(pos); - if let Some(idx) = self.find_element_containing(pos) { - let e = &self.elements[idx]; - let dist_start = pos.saturating_sub(e.range.start); - let dist_end = e.range.end.saturating_sub(pos); - if dist_start <= dist_end { - self.clamp_pos_to_char_boundary(e.range.start) - } else { - self.clamp_pos_to_char_boundary(e.range.end) - } - } else { - pos - } - } - - fn clamp_pos_for_insertion(&self, pos: usize) -> usize { - let pos = self.clamp_pos_to_char_boundary(pos); - // Do not allow inserting into the middle of an element - if let Some(idx) = self.find_element_containing(pos) { - let e = &self.elements[idx]; - // Choose closest edge for insertion - let dist_start = pos.saturating_sub(e.range.start); - let dist_end = e.range.end.saturating_sub(pos); - if dist_start <= dist_end { - self.clamp_pos_to_char_boundary(e.range.start) - } else { - self.clamp_pos_to_char_boundary(e.range.end) - } - } else { - pos - } - } - - fn expand_range_to_element_boundaries(&self, mut range: Range) -> Range { - // Expand to include any intersecting elements fully - loop { - let mut changed = false; - for e in &self.elements { - if e.range.start < range.end && e.range.end > range.start { - let new_start = range.start.min(e.range.start); - let new_end = range.end.max(e.range.end); - if new_start != range.start || new_end != range.end { - range.start = new_start; - range.end = new_end; - changed = true; - } - } - } - if !changed { - break; - } - } - range - } - - fn shift_elements(&mut self, at: usize, removed: usize, inserted: usize) { - // Generic shift: for pure insert, removed = 0; for delete, inserted = 0. - let end = at + removed; - let diff = inserted as isize - removed as isize; - // Remove elements fully deleted by the operation and shift the rest - self.elements - .retain(|e| !(e.range.start >= at && e.range.end <= end)); - for e in &mut self.elements { - if e.range.end <= at { - // before edit - } else if e.range.start >= end { - // after edit - e.range.start = ((e.range.start as isize) + diff) as usize; - e.range.end = ((e.range.end as isize) + diff) as usize; - } else { - // Overlap with element but not fully contained (shouldn't happen when using - // element-aware replace, but degrade gracefully by snapping element to new bounds) - let new_start = at.min(e.range.start); - let new_end = at + inserted.max(e.range.end.saturating_sub(end)); - e.range.start = new_start; - e.range.end = new_end; - } - } - } - - fn update_elements_after_replace(&mut self, start: usize, end: usize, inserted_len: usize) { - self.shift_elements(start, end.saturating_sub(start), inserted_len); - } - - fn prev_atomic_boundary(&self, pos: usize) -> usize { - if pos == 0 { - return 0; - } - // If currently at an element end or inside, jump to start of that element. - if let Some(idx) = self - .elements - .iter() - .position(|e| pos > e.range.start && pos <= e.range.end) - { - return self.elements[idx].range.start; - } - let mut gc = unicode_segmentation::GraphemeCursor::new(pos, self.text.len(), false); - match gc.prev_boundary(&self.text, 0) { - Ok(Some(b)) => { - if let Some(idx) = self.find_element_containing(b) { - self.elements[idx].range.start - } else { - b - } - } - Ok(None) => 0, - Err(_) => pos.saturating_sub(1), - } - } - - fn next_atomic_boundary(&self, pos: usize) -> usize { - if pos >= self.text.len() { - return self.text.len(); - } - // If currently at an element start or inside, jump to end of that element. - if let Some(idx) = self - .elements - .iter() - .position(|e| pos >= e.range.start && pos < e.range.end) - { - return self.elements[idx].range.end; - } - let mut gc = unicode_segmentation::GraphemeCursor::new(pos, self.text.len(), false); - match gc.next_boundary(&self.text, 0) { - Ok(Some(b)) => { - if let Some(idx) = self.find_element_containing(b) { - self.elements[idx].range.end - } else { - b - } - } - Ok(None) => self.text.len(), - Err(_) => pos.saturating_add(1), - } - } - - pub(crate) fn beginning_of_previous_word(&self) -> usize { - let prefix = &self.text[..self.cursor_pos]; - let Some((first_non_ws_idx, ch)) = prefix - .char_indices() - .rev() - .find(|&(_, ch)| !ch.is_whitespace()) - else { - return 0; - }; - let is_separator = is_word_separator(ch); - let mut start = first_non_ws_idx; - for (idx, ch) in prefix[..first_non_ws_idx].char_indices().rev() { - if ch.is_whitespace() || is_word_separator(ch) != is_separator { - start = idx + ch.len_utf8(); - break; - } - start = idx; - } - self.adjust_pos_out_of_elements(start, true) - } - - pub(crate) fn end_of_next_word(&self) -> usize { - let Some(first_non_ws) = self.text[self.cursor_pos..].find(|c: char| !c.is_whitespace()) - else { - return self.text.len(); - }; - let word_start = self.cursor_pos + first_non_ws; - let mut iter = self.text[word_start..].char_indices(); - let Some((_, first_ch)) = iter.next() else { - return word_start; - }; - let is_separator = is_word_separator(first_ch); - let mut end = self.text.len(); - for (idx, ch) in iter { - if ch.is_whitespace() || is_word_separator(ch) != is_separator { - end = word_start + idx; - break; - } - } - self.adjust_pos_out_of_elements(end, false) - } - - fn adjust_pos_out_of_elements(&self, pos: usize, prefer_start: bool) -> usize { - if let Some(idx) = self.find_element_containing(pos) { - let e = &self.elements[idx]; - if prefer_start { - e.range.start - } else { - e.range.end - } - } else { - pos - } - } - - #[expect(clippy::unwrap_used)] - fn wrapped_lines(&self, width: u16) -> Ref<'_, Vec>> { - // Ensure cache is ready (potentially mutably borrow, then drop) - { - let mut cache = self.wrap_cache.borrow_mut(); - let needs_recalc = match cache.as_ref() { - Some(c) => c.width != width, - None => true, - }; - if needs_recalc { - let lines = crate::wrapping::wrap_ranges( - &self.text, - Options::new(width as usize).wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), - ); - *cache = Some(WrapCache { width, lines }); - } - } - - let cache = self.wrap_cache.borrow(); - Ref::map(cache, |c| &c.as_ref().unwrap().lines) - } - - /// Calculate the scroll offset that should be used to satisfy the - /// invariants given the current area size and wrapped lines. - /// - /// - Cursor is always on screen. - /// - No scrolling if content fits in the area. - fn effective_scroll( - &self, - area_height: u16, - lines: &[Range], - current_scroll: u16, - ) -> u16 { - let total_lines = lines.len() as u16; - if area_height >= total_lines { - return 0; - } - - // Where is the cursor within wrapped lines? Prefer assigning boundary positions - // (where pos equals the start of a wrapped line) to that later line. - let cursor_line_idx = - Self::wrapped_line_index_by_start(lines, self.cursor_pos).unwrap_or(0) as u16; - - let max_scroll = total_lines.saturating_sub(area_height); - let mut scroll = current_scroll.min(max_scroll); - - // Ensure cursor is visible within [scroll, scroll + area_height) - if cursor_line_idx < scroll { - scroll = cursor_line_idx; - } else if cursor_line_idx >= scroll + area_height { - scroll = cursor_line_idx + 1 - area_height; - } - scroll - } -} - -impl WidgetRef for &TextArea { - fn render_ref(&self, area: Rect, buf: &mut Buffer) { - let lines = self.wrapped_lines(area.width); - self.render_lines(area, buf, &lines, 0..lines.len()); - } -} - -impl StatefulWidgetRef for &TextArea { - type State = TextAreaState; - - fn render_ref(&self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { - let lines = self.wrapped_lines(area.width); - let scroll = self.effective_scroll(area.height, &lines, state.scroll); - state.scroll = scroll; - - let start = scroll as usize; - let end = (scroll + area.height).min(lines.len() as u16) as usize; - self.render_lines(area, buf, &lines, start..end); - } -} - -impl TextArea { - fn render_lines( - &self, - area: Rect, - buf: &mut Buffer, - lines: &[Range], - range: std::ops::Range, - ) { - for (row, idx) in range.enumerate() { - let r = &lines[idx]; - let y = area.y + row as u16; - let line_range = r.start..r.end - 1; - // Draw base line with default style. - buf.set_string(area.x, y, &self.text[line_range.clone()], Style::default()); - - // Overlay styled segments for elements that intersect this line. - for elem in &self.elements { - // Compute overlap with displayed slice. - let overlap_start = elem.range.start.max(line_range.start); - let overlap_end = elem.range.end.min(line_range.end); - if overlap_start >= overlap_end { - continue; - } - let styled = &self.text[overlap_start..overlap_end]; - let x_off = self.text[line_range.start..overlap_start].width() as u16; - let style = Style::default().fg(Color::Cyan); - buf.set_string(area.x + x_off, y, styled, style); - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - // crossterm types are intentionally not imported here to avoid unused warnings - use pretty_assertions::assert_eq; - use rand::prelude::*; - - fn rand_grapheme(rng: &mut rand::rngs::StdRng) -> String { - let r: u8 = rng.random_range(0..100); - match r { - 0..=4 => "\n".to_string(), - 5..=12 => " ".to_string(), - 13..=35 => (rng.random_range(b'a'..=b'z') as char).to_string(), - 36..=45 => (rng.random_range(b'A'..=b'Z') as char).to_string(), - 46..=52 => (rng.random_range(b'0'..=b'9') as char).to_string(), - 53..=65 => { - // Some emoji (wide graphemes) - let choices = ["👍", "😊", "🐍", "🚀", "🧪", "🌟"]; - choices[rng.random_range(0..choices.len())].to_string() - } - 66..=75 => { - // CJK wide characters - let choices = ["漢", "字", "測", "試", "你", "好", "界", "编", "码"]; - choices[rng.random_range(0..choices.len())].to_string() - } - 76..=85 => { - // Combining mark sequences - let base = ["e", "a", "o", "n", "u"][rng.random_range(0..5)]; - let marks = ["\u{0301}", "\u{0308}", "\u{0302}", "\u{0303}"]; - format!("{base}{}", marks[rng.random_range(0..marks.len())]) - } - 86..=92 => { - // Some non-latin single codepoints (Greek, Cyrillic, Hebrew) - let choices = ["Ω", "β", "Ж", "ю", "ש", "م", "ह"]; - choices[rng.random_range(0..choices.len())].to_string() - } - _ => { - // ZWJ sequences (single graphemes but multi-codepoint) - let choices = [ - "👩\u{200D}💻", // woman technologist - "👨\u{200D}💻", // man technologist - "🏳️\u{200D}🌈", // rainbow flag - ]; - choices[rng.random_range(0..choices.len())].to_string() - } - } - } - - fn ta_with(text: &str) -> TextArea { - let mut t = TextArea::new(); - t.insert_str(text); - t - } - - #[test] - fn insert_and_replace_update_cursor_and_text() { - // insert helpers - let mut t = ta_with("hello"); - t.set_cursor(5); - t.insert_str("!"); - assert_eq!(t.text(), "hello!"); - assert_eq!(t.cursor(), 6); - - t.insert_str_at(0, "X"); - assert_eq!(t.text(), "Xhello!"); - assert_eq!(t.cursor(), 7); - - // Insert after the cursor should not move it - t.set_cursor(1); - let end = t.text().len(); - t.insert_str_at(end, "Y"); - assert_eq!(t.text(), "Xhello!Y"); - assert_eq!(t.cursor(), 1); - - // replace_range cases - // 1) cursor before range - let mut t = ta_with("abcd"); - t.set_cursor(1); - t.replace_range(2..3, "Z"); - assert_eq!(t.text(), "abZd"); - assert_eq!(t.cursor(), 1); - - // 2) cursor inside range - let mut t = ta_with("abcd"); - t.set_cursor(2); - t.replace_range(1..3, "Q"); - assert_eq!(t.text(), "aQd"); - assert_eq!(t.cursor(), 2); - - // 3) cursor after range with shifted by diff - let mut t = ta_with("abcd"); - t.set_cursor(4); - t.replace_range(0..1, "AA"); - assert_eq!(t.text(), "AAbcd"); - assert_eq!(t.cursor(), 5); - } - - #[test] - fn insert_str_at_clamps_to_char_boundary() { - let mut t = TextArea::new(); - t.insert_str("你"); - t.set_cursor(0); - t.insert_str_at(1, "A"); - assert_eq!(t.text(), "A你"); - assert_eq!(t.cursor(), 1); - } - - #[test] - fn set_text_clamps_cursor_to_char_boundary() { - let mut t = TextArea::new(); - t.insert_str("abcd"); - t.set_cursor(1); - t.set_text_clearing_elements("你"); - assert_eq!(t.cursor(), 0); - t.insert_str("a"); - assert_eq!(t.text(), "a你"); - } - - #[test] - fn delete_backward_and_forward_edges() { - let mut t = ta_with("abc"); - t.set_cursor(1); - t.delete_backward(1); - assert_eq!(t.text(), "bc"); - assert_eq!(t.cursor(), 0); - - // deleting backward at start is a no-op - t.set_cursor(0); - t.delete_backward(1); - assert_eq!(t.text(), "bc"); - assert_eq!(t.cursor(), 0); - - // forward delete removes next grapheme - t.set_cursor(1); - t.delete_forward(1); - assert_eq!(t.text(), "b"); - assert_eq!(t.cursor(), 1); - - // forward delete at end is a no-op - t.set_cursor(t.text().len()); - t.delete_forward(1); - assert_eq!(t.text(), "b"); - } - - #[test] - fn delete_backward_word_and_kill_line_variants() { - // delete backward word at end removes the whole previous word - let mut t = ta_with("hello world "); - t.set_cursor(t.text().len()); - t.delete_backward_word(); - assert_eq!(t.text(), "hello "); - assert_eq!(t.cursor(), 8); - - // From inside a word, delete from word start to cursor - let mut t = ta_with("foo bar"); - t.set_cursor(6); // inside "bar" (after 'a') - t.delete_backward_word(); - assert_eq!(t.text(), "foo r"); - assert_eq!(t.cursor(), 4); - - // From end, delete the last word only - let mut t = ta_with("foo bar"); - t.set_cursor(t.text().len()); - t.delete_backward_word(); - assert_eq!(t.text(), "foo "); - assert_eq!(t.cursor(), 4); - - // kill_to_end_of_line when not at EOL - let mut t = ta_with("abc\ndef"); - t.set_cursor(1); // on first line, middle - t.kill_to_end_of_line(); - assert_eq!(t.text(), "a\ndef"); - assert_eq!(t.cursor(), 1); - - // kill_to_end_of_line when at EOL deletes newline - let mut t = ta_with("abc\ndef"); - t.set_cursor(3); // EOL of first line - t.kill_to_end_of_line(); - assert_eq!(t.text(), "abcdef"); - assert_eq!(t.cursor(), 3); - - // kill_to_beginning_of_line from middle of line - let mut t = ta_with("abc\ndef"); - t.set_cursor(5); // on second line, after 'e' - t.kill_to_beginning_of_line(); - assert_eq!(t.text(), "abc\nef"); - - // kill_to_beginning_of_line at beginning of non-first line removes the previous newline - let mut t = ta_with("abc\ndef"); - t.set_cursor(4); // beginning of second line - t.kill_to_beginning_of_line(); - assert_eq!(t.text(), "abcdef"); - assert_eq!(t.cursor(), 3); - } - - #[test] - fn delete_forward_word_variants() { - let mut t = ta_with("hello world "); - t.set_cursor(0); - t.delete_forward_word(); - assert_eq!(t.text(), " world "); - assert_eq!(t.cursor(), 0); - - let mut t = ta_with("hello world "); - t.set_cursor(1); - t.delete_forward_word(); - assert_eq!(t.text(), "h world "); - assert_eq!(t.cursor(), 1); - - let mut t = ta_with("hello world"); - t.set_cursor(t.text().len()); - t.delete_forward_word(); - assert_eq!(t.text(), "hello world"); - assert_eq!(t.cursor(), t.text().len()); - - let mut t = ta_with("foo \nbar"); - t.set_cursor(3); - t.delete_forward_word(); - assert_eq!(t.text(), "foo"); - assert_eq!(t.cursor(), 3); - - let mut t = ta_with("foo\nbar"); - t.set_cursor(3); - t.delete_forward_word(); - assert_eq!(t.text(), "foo"); - assert_eq!(t.cursor(), 3); - - let mut t = ta_with("hello world "); - t.set_cursor(t.text().len() + 10); - t.delete_forward_word(); - assert_eq!(t.text(), "hello world "); - assert_eq!(t.cursor(), t.text().len()); - } - - #[test] - fn delete_forward_word_handles_atomic_elements() { - let mut t = TextArea::new(); - t.insert_element(""); - t.insert_str(" tail"); - - t.set_cursor(0); - t.delete_forward_word(); - assert_eq!(t.text(), " tail"); - assert_eq!(t.cursor(), 0); - - let mut t = TextArea::new(); - t.insert_str(" "); - t.insert_element(""); - t.insert_str(" tail"); - - t.set_cursor(0); - t.delete_forward_word(); - assert_eq!(t.text(), " tail"); - assert_eq!(t.cursor(), 0); - - let mut t = TextArea::new(); - t.insert_str("prefix "); - t.insert_element(""); - t.insert_str(" tail"); - - // cursor in the middle of the element, delete_forward_word deletes the element - let elem_range = t.elements[0].range.clone(); - t.cursor_pos = elem_range.start + (elem_range.len() / 2); - t.delete_forward_word(); - assert_eq!(t.text(), "prefix tail"); - assert_eq!(t.cursor(), elem_range.start); - } - - #[test] - fn delete_backward_word_respects_word_separators() { - let mut t = ta_with("path/to/file"); - t.set_cursor(t.text().len()); - t.delete_backward_word(); - assert_eq!(t.text(), "path/to/"); - assert_eq!(t.cursor(), t.text().len()); - - t.delete_backward_word(); - assert_eq!(t.text(), "path/to"); - assert_eq!(t.cursor(), t.text().len()); - - let mut t = ta_with("foo/ "); - t.set_cursor(t.text().len()); - t.delete_backward_word(); - assert_eq!(t.text(), "foo"); - assert_eq!(t.cursor(), 3); - - let mut t = ta_with("foo /"); - t.set_cursor(t.text().len()); - t.delete_backward_word(); - assert_eq!(t.text(), "foo "); - assert_eq!(t.cursor(), 4); - } - - #[test] - fn delete_forward_word_respects_word_separators() { - let mut t = ta_with("path/to/file"); - t.set_cursor(0); - t.delete_forward_word(); - assert_eq!(t.text(), "/to/file"); - assert_eq!(t.cursor(), 0); - - t.delete_forward_word(); - assert_eq!(t.text(), "to/file"); - assert_eq!(t.cursor(), 0); - - let mut t = ta_with("/ foo"); - t.set_cursor(0); - t.delete_forward_word(); - assert_eq!(t.text(), " foo"); - assert_eq!(t.cursor(), 0); - - let mut t = ta_with(" /foo"); - t.set_cursor(0); - t.delete_forward_word(); - assert_eq!(t.text(), "foo"); - assert_eq!(t.cursor(), 0); - } - - #[test] - fn yank_restores_last_kill() { - let mut t = ta_with("hello"); - t.set_cursor(0); - t.kill_to_end_of_line(); - assert_eq!(t.text(), ""); - assert_eq!(t.cursor(), 0); - - t.yank(); - assert_eq!(t.text(), "hello"); - assert_eq!(t.cursor(), 5); - - let mut t = ta_with("hello world"); - t.set_cursor(t.text().len()); - t.delete_backward_word(); - assert_eq!(t.text(), "hello "); - assert_eq!(t.cursor(), 6); - - t.yank(); - assert_eq!(t.text(), "hello world"); - assert_eq!(t.cursor(), 11); - - let mut t = ta_with("hello"); - t.set_cursor(5); - t.kill_to_beginning_of_line(); - assert_eq!(t.text(), ""); - assert_eq!(t.cursor(), 0); - - t.yank(); - assert_eq!(t.text(), "hello"); - assert_eq!(t.cursor(), 5); - } - - #[test] - fn cursor_left_and_right_handle_graphemes() { - let mut t = ta_with("a👍b"); - t.set_cursor(t.text().len()); - - t.move_cursor_left(); // before 'b' - let after_first_left = t.cursor(); - t.move_cursor_left(); // before '👍' - let after_second_left = t.cursor(); - t.move_cursor_left(); // before 'a' - let after_third_left = t.cursor(); - - assert!(after_first_left < t.text().len()); - assert!(after_second_left < after_first_left); - assert!(after_third_left < after_second_left); - - // Move right back to end safely - t.move_cursor_right(); - t.move_cursor_right(); - t.move_cursor_right(); - assert_eq!(t.cursor(), t.text().len()); - } - - #[test] - fn control_b_and_f_move_cursor() { - let mut t = ta_with("abcd"); - t.set_cursor(1); - - t.input(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL)); - assert_eq!(t.cursor(), 2); - - t.input(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL)); - assert_eq!(t.cursor(), 1); - } - - #[test] - fn control_b_f_fallback_control_chars_move_cursor() { - let mut t = ta_with("abcd"); - t.set_cursor(2); - - // Simulate terminals that send C0 control chars without CONTROL modifier. - // ^B (U+0002) should move left - t.input(KeyEvent::new(KeyCode::Char('\u{0002}'), KeyModifiers::NONE)); - assert_eq!(t.cursor(), 1); - - // ^F (U+0006) should move right - t.input(KeyEvent::new(KeyCode::Char('\u{0006}'), KeyModifiers::NONE)); - assert_eq!(t.cursor(), 2); - } - - #[test] - fn delete_backward_word_alt_keys() { - // Test the custom Alt+Ctrl+h binding - let mut t = ta_with("hello world"); - t.set_cursor(t.text().len()); // cursor at the end - t.input(KeyEvent::new( - KeyCode::Char('h'), - KeyModifiers::CONTROL | KeyModifiers::ALT, - )); - assert_eq!(t.text(), "hello "); - assert_eq!(t.cursor(), 6); - - // Test the standard Alt+Backspace binding - let mut t = ta_with("hello world"); - t.set_cursor(t.text().len()); // cursor at the end - t.input(KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT)); - assert_eq!(t.text(), "hello "); - assert_eq!(t.cursor(), 6); - } - - #[test] - fn delete_backward_word_handles_narrow_no_break_space() { - let mut t = ta_with("32\u{202F}AM"); - t.set_cursor(t.text().len()); - t.input(KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT)); - pretty_assertions::assert_eq!(t.text(), "32\u{202F}"); - pretty_assertions::assert_eq!(t.cursor(), t.text().len()); - } - - #[test] - fn delete_forward_word_with_without_alt_modifier() { - let mut t = ta_with("hello world"); - t.set_cursor(0); - t.input(KeyEvent::new(KeyCode::Delete, KeyModifiers::ALT)); - assert_eq!(t.text(), " world"); - assert_eq!(t.cursor(), 0); - - let mut t = ta_with("hello"); - t.set_cursor(0); - t.input(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE)); - assert_eq!(t.text(), "ello"); - assert_eq!(t.cursor(), 0); - } - - #[test] - fn control_h_backspace() { - // Test Ctrl+H as backspace - let mut t = ta_with("12345"); - t.set_cursor(3); // cursor after '3' - t.input(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)); - assert_eq!(t.text(), "1245"); - assert_eq!(t.cursor(), 2); - - // Test Ctrl+H at beginning (should be no-op) - t.set_cursor(0); - t.input(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)); - assert_eq!(t.text(), "1245"); - assert_eq!(t.cursor(), 0); - - // Test Ctrl+H at end - t.set_cursor(t.text().len()); - t.input(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)); - assert_eq!(t.text(), "124"); - assert_eq!(t.cursor(), 3); - } - - #[cfg_attr(not(windows), ignore = "AltGr modifier only applies on Windows")] - #[test] - fn altgr_ctrl_alt_char_inserts_literal() { - let mut t = ta_with(""); - t.input(KeyEvent::new( - KeyCode::Char('c'), - KeyModifiers::CONTROL | KeyModifiers::ALT, - )); - assert_eq!(t.text(), "c"); - assert_eq!(t.cursor(), 1); - } - - #[test] - fn cursor_vertical_movement_across_lines_and_bounds() { - let mut t = ta_with("short\nloooooooooong\nmid"); - // Place cursor on second line, column 5 - let second_line_start = 6; // after first '\n' - t.set_cursor(second_line_start + 5); - - // Move up: target column preserved, clamped by line length - t.move_cursor_up(); - assert_eq!(t.cursor(), 5); // first line has len 5 - - // Move up again goes to start of text - t.move_cursor_up(); - assert_eq!(t.cursor(), 0); - - // Move down: from start to target col tracked - t.move_cursor_down(); - // On first move down, we should land on second line, at col 0 (target col remembered as 0) - let pos_after_down = t.cursor(); - assert!(pos_after_down >= second_line_start); - - // Move down again to third line; clamp to its length - t.move_cursor_down(); - let third_line_start = t.text().find("mid").unwrap(); - let third_line_end = third_line_start + 3; - assert!(t.cursor() >= third_line_start && t.cursor() <= third_line_end); - - // Moving down at last line jumps to end - t.move_cursor_down(); - assert_eq!(t.cursor(), t.text().len()); - } - - #[test] - fn home_end_and_emacs_style_home_end() { - let mut t = ta_with("one\ntwo\nthree"); - // Position at middle of second line - let second_line_start = t.text().find("two").unwrap(); - t.set_cursor(second_line_start + 1); - - t.move_cursor_to_beginning_of_line(false); - assert_eq!(t.cursor(), second_line_start); - - // Ctrl-A behavior: if at BOL, go to beginning of previous line - t.move_cursor_to_beginning_of_line(true); - assert_eq!(t.cursor(), 0); // beginning of first line - - // Move to EOL of first line - t.move_cursor_to_end_of_line(false); - assert_eq!(t.cursor(), 3); - - // Ctrl-E: if at EOL, go to end of next line - t.move_cursor_to_end_of_line(true); - // end of second line ("two") is right before its '\n' - let end_second_nl = t.text().find("\nthree").unwrap(); - assert_eq!(t.cursor(), end_second_nl); - } - - #[test] - fn end_of_line_or_down_at_end_of_text() { - let mut t = ta_with("one\ntwo"); - // Place cursor at absolute end of the text - t.set_cursor(t.text().len()); - // Should remain at end without panicking - t.move_cursor_to_end_of_line(true); - assert_eq!(t.cursor(), t.text().len()); - - // Also verify behavior when at EOL of a non-final line: - let eol_first_line = 3; // index of '\n' in "one\ntwo" - t.set_cursor(eol_first_line); - t.move_cursor_to_end_of_line(true); - assert_eq!(t.cursor(), t.text().len()); // moves to end of next (last) line - } - - #[test] - fn word_navigation_helpers() { - let t = ta_with(" alpha beta gamma"); - let mut t = t; // make mutable for set_cursor - // Put cursor after "alpha" - let after_alpha = t.text().find("alpha").unwrap() + "alpha".len(); - t.set_cursor(after_alpha); - assert_eq!(t.beginning_of_previous_word(), 2); // skip initial spaces - - // Put cursor at start of beta - let beta_start = t.text().find("beta").unwrap(); - t.set_cursor(beta_start); - assert_eq!(t.end_of_next_word(), beta_start + "beta".len()); - - // If at end, end_of_next_word returns len - t.set_cursor(t.text().len()); - assert_eq!(t.end_of_next_word(), t.text().len()); - } - - #[test] - fn wrapping_and_cursor_positions() { - let mut t = ta_with("hello world here"); - let area = Rect::new(0, 0, 6, 10); // width 6 -> wraps words - // desired height counts wrapped lines - assert!(t.desired_height(area.width) >= 3); - - // Place cursor in "world" - let world_start = t.text().find("world").unwrap(); - t.set_cursor(world_start + 3); - let (_x, y) = t.cursor_pos(area).unwrap(); - assert_eq!(y, 1); // world should be on second wrapped line - - // With state and small height, cursor is mapped onto visible row - let mut state = TextAreaState::default(); - let small_area = Rect::new(0, 0, 6, 1); - // First call: cursor not visible -> effective scroll ensures it is - let (_x, y) = t.cursor_pos_with_state(small_area, state).unwrap(); - assert_eq!(y, 0); - - // Render with state to update actual scroll value - let mut buf = Buffer::empty(small_area); - ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), small_area, &mut buf, &mut state); - // After render, state.scroll should be adjusted so cursor row fits - let effective_lines = t.desired_height(small_area.width); - assert!(state.scroll < effective_lines); - } - - #[test] - fn cursor_pos_with_state_basic_and_scroll_behaviors() { - // Case 1: No wrapping needed, height fits — scroll ignored, y maps directly. - let mut t = ta_with("hello world"); - t.set_cursor(3); - let area = Rect::new(2, 5, 20, 3); - // Even if an absurd scroll is provided, when content fits the area the - // effective scroll is 0 and the cursor position matches cursor_pos. - let bad_state = TextAreaState { scroll: 999 }; - let (x1, y1) = t.cursor_pos(area).unwrap(); - let (x2, y2) = t.cursor_pos_with_state(area, bad_state).unwrap(); - assert_eq!((x2, y2), (x1, y1)); - - // Case 2: Cursor below the current window — y should be clamped to the - // bottom row (area.height - 1) after adjusting effective scroll. - let mut t = ta_with("one two three four five six"); - // Force wrapping to many visual lines. - let wrap_width = 4; - let _ = t.desired_height(wrap_width); - // Put cursor somewhere near the end so it's definitely below the first window. - t.set_cursor(t.text().len().saturating_sub(2)); - let small_area = Rect::new(0, 0, wrap_width, 2); - let state = TextAreaState { scroll: 0 }; - let (_x, y) = t.cursor_pos_with_state(small_area, state).unwrap(); - assert_eq!(y, small_area.y + small_area.height - 1); - - // Case 3: Cursor above the current window — y should be top row (0) - // when the provided scroll is too large. - let mut t = ta_with("alpha beta gamma delta epsilon zeta"); - let wrap_width = 5; - let lines = t.desired_height(wrap_width); - // Place cursor near start so an excessive scroll moves it to top row. - t.set_cursor(1); - let area = Rect::new(0, 0, wrap_width, 3); - let state = TextAreaState { - scroll: lines.saturating_mul(2), - }; - let (_x, y) = t.cursor_pos_with_state(area, state).unwrap(); - assert_eq!(y, area.y); - } - - #[test] - fn wrapped_navigation_across_visual_lines() { - let mut t = ta_with("abcdefghij"); - // Force wrapping at width 4: lines -> ["abcd", "efgh", "ij"] - let _ = t.desired_height(4); - - // From the very start, moving down should go to the start of the next wrapped line (index 4) - t.set_cursor(0); - t.move_cursor_down(); - assert_eq!(t.cursor(), 4); - - // Cursor at boundary index 4 should be displayed at start of second wrapped line - t.set_cursor(4); - let area = Rect::new(0, 0, 4, 10); - let (x, y) = t.cursor_pos(area).unwrap(); - assert_eq!((x, y), (0, 1)); - - // With state and small height, cursor should be visible at row 0, col 0 - let small_area = Rect::new(0, 0, 4, 1); - let state = TextAreaState::default(); - let (x, y) = t.cursor_pos_with_state(small_area, state).unwrap(); - assert_eq!((x, y), (0, 0)); - - // Place cursor in the middle of the second wrapped line ("efgh"), at 'g' - t.set_cursor(6); - // Move up should go to same column on previous wrapped line -> index 2 ('c') - t.move_cursor_up(); - assert_eq!(t.cursor(), 2); - - // Move down should return to same position on the next wrapped line -> back to index 6 ('g') - t.move_cursor_down(); - assert_eq!(t.cursor(), 6); - - // Move down again should go to third wrapped line. Target col is 2, but the line has len 2 -> clamp to end - t.move_cursor_down(); - assert_eq!(t.cursor(), t.text().len()); - } - - #[test] - fn cursor_pos_with_state_after_movements() { - let mut t = ta_with("abcdefghij"); - // Wrap width 4 -> visual lines: abcd | efgh | ij - let _ = t.desired_height(4); - let area = Rect::new(0, 0, 4, 2); - let mut state = TextAreaState::default(); - let mut buf = Buffer::empty(area); - - // Start at beginning - t.set_cursor(0); - ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state); - let (x, y) = t.cursor_pos_with_state(area, state).unwrap(); - assert_eq!((x, y), (0, 0)); - - // Move down to second visual line; should be at bottom row (row 1) within 2-line viewport - t.move_cursor_down(); - ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state); - let (x, y) = t.cursor_pos_with_state(area, state).unwrap(); - assert_eq!((x, y), (0, 1)); - - // Move down to third visual line; viewport scrolls and keeps cursor on bottom row - t.move_cursor_down(); - ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state); - let (x, y) = t.cursor_pos_with_state(area, state).unwrap(); - assert_eq!((x, y), (0, 1)); - - // Move up to second visual line; with current scroll, it appears on top row - t.move_cursor_up(); - ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state); - let (x, y) = t.cursor_pos_with_state(area, state).unwrap(); - assert_eq!((x, y), (0, 0)); - - // Column preservation across moves: set to col 2 on first line, move down - t.set_cursor(2); - ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state); - let (x0, y0) = t.cursor_pos_with_state(area, state).unwrap(); - assert_eq!((x0, y0), (2, 0)); - t.move_cursor_down(); - ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state); - let (x1, y1) = t.cursor_pos_with_state(area, state).unwrap(); - assert_eq!((x1, y1), (2, 1)); - } - - #[test] - fn wrapped_navigation_with_newlines_and_spaces() { - // Include spaces and an explicit newline to exercise boundaries - let mut t = ta_with("word1 word2\nword3"); - // Width 6 will wrap "word1 " and then "word2" before the newline - let _ = t.desired_height(6); - - // Put cursor on the second wrapped line before the newline, at column 1 of "word2" - let start_word2 = t.text().find("word2").unwrap(); - t.set_cursor(start_word2 + 1); - - // Up should go to first wrapped line, column 1 -> index 1 - t.move_cursor_up(); - assert_eq!(t.cursor(), 1); - - // Down should return to the same visual column on "word2" - t.move_cursor_down(); - assert_eq!(t.cursor(), start_word2 + 1); - - // Down again should cross the logical newline to the next visual line ("word3"), clamped to its length if needed - t.move_cursor_down(); - let start_word3 = t.text().find("word3").unwrap(); - assert!(t.cursor() >= start_word3 && t.cursor() <= start_word3 + "word3".len()); - } - - #[test] - fn wrapped_navigation_with_wide_graphemes() { - // Four thumbs up, each of display width 2, with width 3 to force wrapping inside grapheme boundaries - let mut t = ta_with("👍👍👍👍"); - let _ = t.desired_height(3); - - // Put cursor after the second emoji (which should be on first wrapped line) - t.set_cursor("👍👍".len()); - - // Move down should go to the start of the next wrapped line (same column preserved but clamped) - t.move_cursor_down(); - // We expect to land somewhere within the third emoji or at the start of it - let pos_after_down = t.cursor(); - assert!(pos_after_down >= "👍👍".len()); - - // Moving up should take us back to the original position - t.move_cursor_up(); - assert_eq!(t.cursor(), "👍👍".len()); - } - - #[test] - fn fuzz_textarea_randomized() { - // Deterministic seed for reproducibility - // Seed the RNG based on the current day in Pacific Time (PST/PDT). This - // keeps the fuzz test deterministic within a day while still varying - // day-to-day to improve coverage. - let pst_today_seed: u64 = (chrono::Utc::now() - chrono::Duration::hours(8)) - .date_naive() - .and_hms_opt(0, 0, 0) - .unwrap() - .and_utc() - .timestamp() as u64; - let mut rng = rand::rngs::StdRng::seed_from_u64(pst_today_seed); - - for _case in 0..500 { - let mut ta = TextArea::new(); - let mut state = TextAreaState::default(); - // Track element payloads we insert. Payloads use characters '[' and ']' which - // are not produced by rand_grapheme(), avoiding accidental collisions. - let mut elem_texts: Vec = Vec::new(); - let mut next_elem_id: usize = 0; - // Start with a random base string - let base_len = rng.random_range(0..30); - let mut base = String::new(); - for _ in 0..base_len { - base.push_str(&rand_grapheme(&mut rng)); - } - ta.set_text_clearing_elements(&base); - // Choose a valid char boundary for initial cursor - let mut boundaries: Vec = vec![0]; - boundaries.extend(ta.text().char_indices().map(|(i, _)| i).skip(1)); - boundaries.push(ta.text().len()); - let init = boundaries[rng.random_range(0..boundaries.len())]; - ta.set_cursor(init); - - let mut width: u16 = rng.random_range(1..=12); - let mut height: u16 = rng.random_range(1..=4); - - for _step in 0..60 { - // Mostly stable width/height, occasionally change - if rng.random_bool(0.1) { - width = rng.random_range(1..=12); - } - if rng.random_bool(0.1) { - height = rng.random_range(1..=4); - } - - // Pick an operation - match rng.random_range(0..18) { - 0 => { - // insert small random string at cursor - let len = rng.random_range(0..6); - let mut s = String::new(); - for _ in 0..len { - s.push_str(&rand_grapheme(&mut rng)); - } - ta.insert_str(&s); - } - 1 => { - // replace_range with small random slice - let mut b: Vec = vec![0]; - b.extend(ta.text().char_indices().map(|(i, _)| i).skip(1)); - b.push(ta.text().len()); - let i1 = rng.random_range(0..b.len()); - let i2 = rng.random_range(0..b.len()); - let (start, end) = if b[i1] <= b[i2] { - (b[i1], b[i2]) - } else { - (b[i2], b[i1]) - }; - let insert_len = rng.random_range(0..=4); - let mut s = String::new(); - for _ in 0..insert_len { - s.push_str(&rand_grapheme(&mut rng)); - } - let before = ta.text().len(); - // If the chosen range intersects an element, replace_range will expand to - // element boundaries, so the naive size delta assertion does not hold. - let intersects_element = elem_texts.iter().any(|payload| { - if let Some(pstart) = ta.text().find(payload) { - let pend = pstart + payload.len(); - pstart < end && pend > start - } else { - false - } - }); - ta.replace_range(start..end, &s); - if !intersects_element { - let after = ta.text().len(); - assert_eq!( - after as isize, - before as isize + (s.len() as isize) - ((end - start) as isize) - ); - } - } - 2 => ta.delete_backward(rng.random_range(0..=3)), - 3 => ta.delete_forward(rng.random_range(0..=3)), - 4 => ta.delete_backward_word(), - 5 => ta.kill_to_beginning_of_line(), - 6 => ta.kill_to_end_of_line(), - 7 => ta.move_cursor_left(), - 8 => ta.move_cursor_right(), - 9 => ta.move_cursor_up(), - 10 => ta.move_cursor_down(), - 11 => ta.move_cursor_to_beginning_of_line(true), - 12 => ta.move_cursor_to_end_of_line(true), - 13 => { - // Insert an element with a unique sentinel payload - let payload = - format!("[[EL#{}:{}]]", next_elem_id, rng.random_range(1000..9999)); - next_elem_id += 1; - ta.insert_element(&payload); - elem_texts.push(payload); - } - 14 => { - // Try inserting inside an existing element (should clamp to boundary) - if let Some(payload) = elem_texts.choose(&mut rng).cloned() - && let Some(start) = ta.text().find(&payload) - { - let end = start + payload.len(); - if end - start > 2 { - let pos = rng.random_range(start + 1..end - 1); - let ins = rand_grapheme(&mut rng); - ta.insert_str_at(pos, &ins); - } - } - } - 15 => { - // Replace a range that intersects an element -> whole element should be replaced - if let Some(payload) = elem_texts.choose(&mut rng).cloned() - && let Some(start) = ta.text().find(&payload) - { - let end = start + payload.len(); - // Create an intersecting range [start-δ, end-δ2) - let mut s = start.saturating_sub(rng.random_range(0..=2)); - let mut e = (end + rng.random_range(0..=2)).min(ta.text().len()); - // Align to char boundaries to satisfy String::replace_range contract - let txt = ta.text(); - while s > 0 && !txt.is_char_boundary(s) { - s -= 1; - } - while e < txt.len() && !txt.is_char_boundary(e) { - e += 1; - } - if s < e { - // Small replacement text - let mut srep = String::new(); - for _ in 0..rng.random_range(0..=2) { - srep.push_str(&rand_grapheme(&mut rng)); - } - ta.replace_range(s..e, &srep); - } - } - } - 16 => { - // Try setting the cursor to a position inside an element; it should clamp out - if let Some(payload) = elem_texts.choose(&mut rng).cloned() - && let Some(start) = ta.text().find(&payload) - { - let end = start + payload.len(); - if end - start > 2 { - let pos = rng.random_range(start + 1..end - 1); - ta.set_cursor(pos); - } - } - } - _ => { - // Jump to word boundaries - if rng.random_bool(0.5) { - let p = ta.beginning_of_previous_word(); - ta.set_cursor(p); - } else { - let p = ta.end_of_next_word(); - ta.set_cursor(p); - } - } - } - - // Sanity invariants - assert!(ta.cursor() <= ta.text().len()); - - // Element invariants - for payload in &elem_texts { - if let Some(start) = ta.text().find(payload) { - let end = start + payload.len(); - // 1) Text inside elements matches the initially set payload - assert_eq!(&ta.text()[start..end], payload); - // 2) Cursor is never strictly inside an element - let c = ta.cursor(); - assert!( - c <= start || c >= end, - "cursor inside element: {start}..{end} at {c}" - ); - } - } - - // Render and compute cursor positions; ensure they are in-bounds and do not panic - let area = Rect::new(0, 0, width, height); - // Stateless render into an area tall enough for all wrapped lines - let total_lines = ta.desired_height(width); - let full_area = Rect::new(0, 0, width, total_lines.max(1)); - let mut buf = Buffer::empty(full_area); - ratatui::widgets::WidgetRef::render_ref(&(&ta), full_area, &mut buf); - - // cursor_pos: x must be within width when present - let _ = ta.cursor_pos(area); - - // cursor_pos_with_state: always within viewport rows - let (_x, _y) = ta - .cursor_pos_with_state(area, state) - .unwrap_or((area.x, area.y)); - - // Stateful render should not panic, and updates scroll - let mut sbuf = Buffer::empty(area); - ratatui::widgets::StatefulWidgetRef::render_ref( - &(&ta), - area, - &mut sbuf, - &mut state, - ); - - // After wrapping, desired height equals the number of lines we would render without scroll - let total_lines = total_lines as usize; - // state.scroll must not exceed total_lines when content fits within area height - if (height as usize) >= total_lines { - assert_eq!(state.scroll, 0); - } - } - } - } -} diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs deleted file mode 100644 index 8cbab2db810..00000000000 --- a/codex-rs/tui2/src/chatwidget.rs +++ /dev/null @@ -1,4849 +0,0 @@ -//! The main Codex TUI chat surface. -//! -//! `ChatWidget` consumes protocol events, builds and updates history cells, and drives rendering -//! for both the main viewport and overlay UIs. -//! -//! The UI has both committed transcript cells (finalized `HistoryCell`s) and an in-flight active -//! cell (`ChatWidget.active_cell`) that can mutate in place while streaming (often representing a -//! coalesced exec/tool group). The transcript overlay (`Ctrl+T`) renders committed cells plus a -//! cached, render-only live tail derived from the current active cell so in-flight tool calls are -//! visible immediately. -//! -//! The transcript overlay is kept in sync by `App::overlay_forward_event`, which syncs a live tail -//! during draws using `active_cell_transcript_key()` and `active_cell_transcript_lines()`. The -//! cache key is designed to change when the active cell mutates in place or when its transcript -//! output is time-dependent so the overlay can refresh its cached tail without rebuilding it on -//! every draw. -//! -//! The bottom pane exposes a single "task running" indicator that drives the spinner and interrupt -//! hints. This module treats that indicator as derived UI-busy state: it is set while an agent turn -//! is in progress and while MCP server startup is in progress. Those lifecycles are tracked -//! independently (`agent_turn_running` and `mcp_startup_status`) and synchronized via -//! `update_task_running_state`. -use std::collections::HashMap; -use std::collections::HashSet; -use std::collections::VecDeque; -use std::path::PathBuf; -use std::sync::Arc; -use std::time::Duration; -use std::time::Instant; - -use codex_app_server_protocol::AuthMode; -use codex_backend_client::Client as BackendClient; -use codex_core::config::Config; -use codex_core::config::ConstraintResult; -use codex_core::config::types::Notifications; -use codex_core::features::FEATURES; -use codex_core::features::Feature; -use codex_core::git_info::current_branch_name; -use codex_core::git_info::local_git_branches; -use codex_core::models_manager::manager::ModelsManager; -use codex_core::project_doc::DEFAULT_PROJECT_DOC_FILENAME; -use codex_core::protocol::AgentMessageDeltaEvent; -use codex_core::protocol::AgentMessageEvent; -use codex_core::protocol::AgentReasoningDeltaEvent; -use codex_core::protocol::AgentReasoningEvent; -use codex_core::protocol::AgentReasoningRawContentDeltaEvent; -use codex_core::protocol::AgentReasoningRawContentEvent; -use codex_core::protocol::ApplyPatchApprovalRequestEvent; -use codex_core::protocol::BackgroundEventEvent; -use codex_core::protocol::CreditsSnapshot; -use codex_core::protocol::DeprecationNoticeEvent; -use codex_core::protocol::ErrorEvent; -use codex_core::protocol::Event; -use codex_core::protocol::EventMsg; -use codex_core::protocol::ExecApprovalRequestEvent; -use codex_core::protocol::ExecCommandBeginEvent; -use codex_core::protocol::ExecCommandEndEvent; -use codex_core::protocol::ExecCommandOutputDeltaEvent; -use codex_core::protocol::ExecCommandSource; -use codex_core::protocol::ExitedReviewModeEvent; -use codex_core::protocol::ListCustomPromptsResponseEvent; -use codex_core::protocol::ListSkillsResponseEvent; -use codex_core::protocol::McpListToolsResponseEvent; -use codex_core::protocol::McpStartupCompleteEvent; -use codex_core::protocol::McpStartupStatus; -use codex_core::protocol::McpStartupUpdateEvent; -use codex_core::protocol::McpToolCallBeginEvent; -use codex_core::protocol::McpToolCallEndEvent; -use codex_core::protocol::Op; -use codex_core::protocol::PatchApplyBeginEvent; -use codex_core::protocol::RateLimitSnapshot; -use codex_core::protocol::ReviewRequest; -use codex_core::protocol::ReviewTarget; -use codex_core::protocol::SkillsListEntry; -use codex_core::protocol::StreamErrorEvent; -use codex_core::protocol::TerminalInteractionEvent; -use codex_core::protocol::TokenUsage; -use codex_core::protocol::TokenUsageInfo; -use codex_core::protocol::TurnAbortReason; -use codex_core::protocol::TurnCompleteEvent; -use codex_core::protocol::TurnDiffEvent; -use codex_core::protocol::UndoCompletedEvent; -use codex_core::protocol::UndoStartedEvent; -use codex_core::protocol::UserMessageEvent; -use codex_core::protocol::ViewImageToolCallEvent; -use codex_core::protocol::WarningEvent; -use codex_core::protocol::WebSearchBeginEvent; -use codex_core::protocol::WebSearchEndEvent; -use codex_core::skills::model::SkillInterface; -use codex_core::skills::model::SkillMetadata; -use codex_otel::OtelManager; -use codex_protocol::ThreadId; -use codex_protocol::account::PlanType; -use codex_protocol::approvals::ElicitationRequestEvent; -use codex_protocol::config_types::CollaborationMode; -use codex_protocol::config_types::Settings; -use codex_protocol::models::local_image_label_text; -use codex_protocol::parse_command::ParsedCommand; -use codex_protocol::user_input::TextElement; -use codex_protocol::user_input::UserInput; -use crossterm::event::KeyCode; -use crossterm::event::KeyEvent; -use crossterm::event::KeyEventKind; -use crossterm::event::KeyModifiers; -use rand::Rng; -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -use ratatui::style::Color; -use ratatui::style::Modifier; -use ratatui::style::Style; -use ratatui::style::Stylize; -use ratatui::text::Line; -use ratatui::widgets::Paragraph; -use ratatui::widgets::Wrap; -use tokio::sync::mpsc::UnboundedSender; -use tokio::task::JoinHandle; -use tracing::debug; - -use crate::app_event::AppEvent; -use crate::app_event::ExitMode; -#[cfg(target_os = "windows")] -use crate::app_event::WindowsSandboxEnableMode; -use crate::app_event::WindowsSandboxFallbackReason; -use crate::app_event_sender::AppEventSender; -use crate::bottom_pane::ApprovalRequest; -use crate::bottom_pane::BetaFeatureItem; -use crate::bottom_pane::BottomPane; -use crate::bottom_pane::BottomPaneParams; -use crate::bottom_pane::CancellationEvent; -use crate::bottom_pane::DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED; -use crate::bottom_pane::ExperimentalFeaturesView; -use crate::bottom_pane::InputResult; -use crate::bottom_pane::LocalImageAttachment; -use crate::bottom_pane::QUIT_SHORTCUT_TIMEOUT; -use crate::bottom_pane::SelectionAction; -use crate::bottom_pane::SelectionItem; -use crate::bottom_pane::SelectionViewParams; -use crate::bottom_pane::custom_prompt_view::CustomPromptView; -use crate::bottom_pane::popup_consts::standard_popup_hint_line; -use crate::clipboard_paste::paste_image_to_temp_png; -use crate::collab; -use crate::collaboration_modes; -use crate::diff_render::display_path_for; -use crate::exec_cell::CommandOutput; -use crate::exec_cell::ExecCell; -use crate::exec_cell::new_active_exec_command; -use crate::get_git_diff::get_git_diff; -use crate::history_cell; -use crate::history_cell::AgentMessageCell; -use crate::history_cell::HistoryCell; -use crate::history_cell::McpToolCallCell; -use crate::history_cell::PlainHistoryCell; -use crate::key_hint; -use crate::key_hint::KeyBinding; -use crate::markdown::append_markdown; -use crate::render::Insets; -use crate::render::renderable::ColumnRenderable; -use crate::render::renderable::FlexRenderable; -use crate::render::renderable::Renderable; -use crate::render::renderable::RenderableExt; -use crate::render::renderable::RenderableItem; -use crate::slash_command::SlashCommand; -use crate::status::RateLimitSnapshotDisplay; -use crate::text_formatting::truncate_text; -use crate::tui::FrameRequester; -use crate::ui_consts::DEFAULT_MODEL_DISPLAY_NAME; -mod interrupts; -use self::interrupts::InterruptManager; -mod agent; -use self::agent::spawn_agent; -use self::agent::spawn_agent_from_existing; -mod session_header; -use self::session_header::SessionHeader; -use crate::streaming::controller::StreamController; -use crate::version::CODEX_CLI_VERSION; -use std::path::Path; - -use chrono::Local; -use codex_common::approval_presets::ApprovalPreset; -use codex_common::approval_presets::builtin_approval_presets; -use codex_core::AuthManager; -use codex_core::CodexAuth; -use codex_core::ThreadManager; -use codex_core::protocol::AskForApproval; -use codex_core::protocol::SandboxPolicy; -use codex_file_search::FileMatch; -use codex_protocol::openai_models::ModelPreset; -use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; -use codex_protocol::plan_tool::UpdatePlanArgs; -use strum::IntoEnumIterator; - -const USER_SHELL_COMMAND_HELP_TITLE: &str = "Prefix a command with ! to run it locally"; -const USER_SHELL_COMMAND_HELP_HINT: &str = "Example: !ls"; -// Track information about an in-flight exec command. -struct RunningCommand { - command: Vec, - parsed_cmd: Vec, - source: ExecCommandSource, -} - -struct UnifiedExecWaitState { - command_display: String, -} - -impl UnifiedExecWaitState { - fn new(command_display: String) -> Self { - Self { command_display } - } - - fn is_duplicate(&self, command_display: &str) -> bool { - self.command_display == command_display - } -} - -const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 3] = [75.0, 90.0, 95.0]; -const NUDGE_MODEL_SLUG: &str = "gpt-5.1-codex-mini"; -const RATE_LIMIT_SWITCH_PROMPT_THRESHOLD: f64 = 90.0; - -#[derive(Default)] -struct RateLimitWarningState { - secondary_index: usize, - primary_index: usize, -} - -impl RateLimitWarningState { - fn take_warnings( - &mut self, - secondary_used_percent: Option, - secondary_window_minutes: Option, - primary_used_percent: Option, - primary_window_minutes: Option, - ) -> Vec { - let reached_secondary_cap = - matches!(secondary_used_percent, Some(percent) if percent == 100.0); - let reached_primary_cap = matches!(primary_used_percent, Some(percent) if percent == 100.0); - if reached_secondary_cap || reached_primary_cap { - return Vec::new(); - } - - let mut warnings = Vec::new(); - - if let Some(secondary_used_percent) = secondary_used_percent { - let mut highest_secondary: Option = None; - while self.secondary_index < RATE_LIMIT_WARNING_THRESHOLDS.len() - && secondary_used_percent >= RATE_LIMIT_WARNING_THRESHOLDS[self.secondary_index] - { - highest_secondary = Some(RATE_LIMIT_WARNING_THRESHOLDS[self.secondary_index]); - self.secondary_index += 1; - } - if let Some(threshold) = highest_secondary { - let limit_label = secondary_window_minutes - .map(get_limits_duration) - .unwrap_or_else(|| "weekly".to_string()); - let remaining_percent = 100.0 - threshold; - warnings.push(format!( - "Heads up, you have less than {remaining_percent:.0}% of your {limit_label} limit left. Run /status for a breakdown." - )); - } - } - - if let Some(primary_used_percent) = primary_used_percent { - let mut highest_primary: Option = None; - while self.primary_index < RATE_LIMIT_WARNING_THRESHOLDS.len() - && primary_used_percent >= RATE_LIMIT_WARNING_THRESHOLDS[self.primary_index] - { - highest_primary = Some(RATE_LIMIT_WARNING_THRESHOLDS[self.primary_index]); - self.primary_index += 1; - } - if let Some(threshold) = highest_primary { - let limit_label = primary_window_minutes - .map(get_limits_duration) - .unwrap_or_else(|| "5h".to_string()); - let remaining_percent = 100.0 - threshold; - warnings.push(format!( - "Heads up, you have less than {remaining_percent:.0}% of your {limit_label} limit left. Run /status for a breakdown." - )); - } - } - - warnings - } -} - -pub(crate) fn get_limits_duration(windows_minutes: i64) -> String { - const MINUTES_PER_HOUR: i64 = 60; - const MINUTES_PER_DAY: i64 = 24 * MINUTES_PER_HOUR; - const MINUTES_PER_WEEK: i64 = 7 * MINUTES_PER_DAY; - const MINUTES_PER_MONTH: i64 = 30 * MINUTES_PER_DAY; - const ROUNDING_BIAS_MINUTES: i64 = 3; - - let windows_minutes = windows_minutes.max(0); - - if windows_minutes <= MINUTES_PER_DAY.saturating_add(ROUNDING_BIAS_MINUTES) { - let adjusted = windows_minutes.saturating_add(ROUNDING_BIAS_MINUTES); - let hours = std::cmp::max(1, adjusted / MINUTES_PER_HOUR); - format!("{hours}h") - } else if windows_minutes <= MINUTES_PER_WEEK.saturating_add(ROUNDING_BIAS_MINUTES) { - "weekly".to_string() - } else if windows_minutes <= MINUTES_PER_MONTH.saturating_add(ROUNDING_BIAS_MINUTES) { - "monthly".to_string() - } else { - "annual".to_string() - } -} - -/// Common initialization parameters shared by all `ChatWidget` constructors. -pub(crate) struct ChatWidgetInit { - pub(crate) config: Config, - pub(crate) frame_requester: FrameRequester, - pub(crate) app_event_tx: AppEventSender, - pub(crate) initial_user_message: Option, - pub(crate) enhanced_keys_supported: bool, - pub(crate) auth_manager: Arc, - pub(crate) models_manager: Arc, - pub(crate) feedback: codex_feedback::CodexFeedback, - pub(crate) is_first_run: bool, - pub(crate) model: Option, - pub(crate) otel_manager: OtelManager, -} - -#[derive(Default)] -enum RateLimitSwitchPromptState { - #[default] - Idle, - Pending, - Shown, -} - -/// Maintains the per-session UI state and interaction state machines for the chat screen. -/// -/// `ChatWidget` owns the state derived from the protocol event stream (history cells, streaming -/// buffers, bottom-pane overlays, and transient status text) and turns key presses into user -/// intent (`Op` submissions and `AppEvent` requests). -/// -/// It is not responsible for running the agent itself; it reflects progress by updating UI state -/// and by sending requests back to codex-core. -/// -/// Quit/interrupt behavior intentionally spans layers: the bottom pane owns local input routing -/// (which view gets Ctrl+C), while `ChatWidget` owns process-level decisions such as interrupting -/// active work, arming the double-press quit shortcut, and requesting shutdown-first exit. -pub(crate) struct ChatWidget { - app_event_tx: AppEventSender, - codex_op_tx: UnboundedSender, - bottom_pane: BottomPane, - active_cell: Option>, - /// Monotonic-ish counter used to invalidate transcript overlay caching. - /// - /// The transcript overlay appends a cached "live tail" for the current active cell. Most - /// active-cell updates are mutations of the *existing* cell (not a replacement), so pointer - /// identity alone is not a good cache key. - /// - /// Callers bump this whenever the active cell's transcript output could change without - /// flushing. It is intentionally allowed to wrap, which implies a rare one-time cache collision - /// where the overlay may briefly treat new tail content as already cached. - active_cell_revision: u64, - config: Config, - /// Stored collaboration mode with model and reasoning effort. - /// - /// When collaboration modes feature is enabled, this is initialized to the first preset. - /// When disabled, this is Custom. The model and reasoning effort are stored here instead of - /// being read from config or current_model. - stored_collaboration_mode: CollaborationMode, - auth_manager: Arc, - models_manager: Arc, - otel_manager: OtelManager, - session_header: SessionHeader, - initial_user_message: Option, - token_info: Option, - rate_limit_snapshot: Option, - plan_type: Option, - rate_limit_warnings: RateLimitWarningState, - rate_limit_switch_prompt: RateLimitSwitchPromptState, - rate_limit_poller: Option>, - // Stream lifecycle controller - stream_controller: Option, - running_commands: HashMap, - suppressed_exec_calls: HashSet, - last_unified_wait: Option, - task_complete_pending: bool, - /// Tracks whether codex-core currently considers an agent turn to be in progress. - /// - /// This is kept separate from `mcp_startup_status` so that MCP startup progress (or completion) - /// can update the status header without accidentally clearing the spinner for an active turn. - agent_turn_running: bool, - /// Tracks per-server MCP startup state while startup is in progress. - /// - /// The map is `Some(_)` from the first `McpStartupUpdate` until `McpStartupComplete`, and the - /// bottom pane is treated as "running" while this is populated, even if no agent turn is - /// currently executing. - mcp_startup_status: Option>, - // Queue of interruptive UI events deferred during an active write cycle - interrupts: InterruptManager, - // Accumulates the current reasoning block text to extract a header - reasoning_buffer: String, - // Accumulates full reasoning content for transcript-only recording - full_reasoning_buffer: String, - // Current status header shown in the status indicator. - current_status_header: String, - // Previous status header to restore after a transient stream retry. - retry_status_header: Option, - conversation_id: Option, - forked_from: Option, - frame_requester: FrameRequester, - // Whether to include the initial welcome banner on session configured - show_welcome_banner: bool, - // When resuming an existing session (selected via resume picker), avoid an - // immediate redraw on SessionConfigured to prevent a gratuitous UI flicker. - suppress_session_configured_redraw: bool, - // User messages queued while a turn is in progress - queued_user_messages: VecDeque, - // Pending notification to show when unfocused on next Draw - pending_notification: Option, - /// When `Some`, the user has pressed a quit shortcut and the second press - /// must occur before `quit_shortcut_expires_at`. - quit_shortcut_expires_at: Option, - /// Tracks which quit shortcut key was pressed first. - /// - /// We require the second press to match this key so `Ctrl+C` followed by - /// `Ctrl+D` (or vice versa) doesn't quit accidentally. - quit_shortcut_key: Option, - // Simple review mode flag; used to adjust layout and banners. - is_review_mode: bool, - // Snapshot of token usage to restore after review mode exits. - pre_review_token_info: Option>, - // Whether the next streamed assistant content should be preceded by a final message separator. - // - // This is set whenever we insert a visible history cell that conceptually belongs to a turn. - // The separator itself is only rendered if the turn recorded "work" activity (see - // `had_work_activity`). - needs_final_message_separator: bool, - // Whether the current turn performed "work" (exec commands, MCP tool calls, patch applications). - // - // This gates rendering of the "Worked for …" separator so purely conversational turns don't - // show an empty divider. It is reset when the separator is emitted. - had_work_activity: bool, - // Status-indicator elapsed seconds captured at the last emitted final-message separator. - // - // This lets the separator show per-chunk work time (since the previous separator) rather than - // the total task-running time reported by the status indicator. - last_separator_elapsed_secs: Option, - - last_rendered_width: std::cell::Cell>, - // Feedback sink for /feedback - feedback: codex_feedback::CodexFeedback, - // Current session rollout path (if known) - current_rollout_path: Option, -} - -/// Snapshot of active-cell state that affects transcript overlay rendering. -/// -/// The overlay keeps a cached "live tail" for the in-flight cell; this key lets -/// it cheaply decide when to recompute that tail as the active cell evolves. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) struct ActiveCellTranscriptKey { - /// Cache-busting revision for in-place updates. - /// - /// Many active cells are updated incrementally while streaming (for example when exec groups - /// add output or change status), and the transcript overlay caches its live tail, so this - /// revision gives a cheap way to say "same active cell, but its transcript output is different - /// now". Callers bump it on any mutation that can affect `HistoryCell::transcript_lines`. - pub(crate) revision: u64, - /// Whether the active cell continues the prior stream, which affects - /// spacing between transcript blocks. - pub(crate) is_stream_continuation: bool, - /// Optional animation tick for time-dependent transcript output. - /// - /// When this changes, the overlay recomputes the cached tail even if the revision and width - /// are unchanged, which is how shimmer/spinner visuals can animate in the overlay without any - /// underlying data change. - pub(crate) animation_tick: Option, -} - -pub(crate) struct UserMessage { - text: String, - local_images: Vec, - text_elements: Vec, -} - -impl From for UserMessage { - fn from(text: String) -> Self { - Self { - text, - local_images: Vec::new(), - // Plain text conversion has no UI element ranges. - text_elements: Vec::new(), - } - } -} - -impl From<&str> for UserMessage { - fn from(text: &str) -> Self { - Self { - text: text.to_string(), - local_images: Vec::new(), - // Plain text conversion has no UI element ranges. - text_elements: Vec::new(), - } - } -} - -pub(crate) fn create_initial_user_message( - text: Option, - local_image_paths: Vec, - text_elements: Vec, -) -> Option { - let text = text.unwrap_or_default(); - if text.is_empty() && local_image_paths.is_empty() { - None - } else { - let local_images = local_image_paths - .into_iter() - .enumerate() - .map(|(idx, path)| LocalImageAttachment { - placeholder: local_image_label_text(idx + 1), - path, - }) - .collect(); - Some(UserMessage { - text, - local_images, - text_elements, - }) - } -} - -// When merging multiple queued drafts (e.g., after interrupt), each draft starts numbering -// its attachments at [Image #1]. Reassign placeholder labels based on the attachment list so -// the combined local_image_paths order matches the labels, even if placeholders were moved -// in the text (e.g., [Image #2] appearing before [Image #1]). -fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize) -> UserMessage { - let UserMessage { - text, - text_elements, - local_images, - } = message; - if local_images.is_empty() { - return UserMessage { - text, - text_elements, - local_images, - }; - } - - let mut mapping: HashMap = HashMap::new(); - let mut remapped_images = Vec::new(); - for attachment in local_images { - let new_placeholder = local_image_label_text(*next_label); - *next_label += 1; - mapping.insert(attachment.placeholder.clone(), new_placeholder.clone()); - remapped_images.push(LocalImageAttachment { - placeholder: new_placeholder, - path: attachment.path, - }); - } - - let mut elements = text_elements; - elements.sort_by_key(|elem| elem.byte_range.start); - - let mut cursor = 0usize; - let mut rebuilt = String::new(); - let mut rebuilt_elements = Vec::new(); - for mut elem in elements { - let start = elem.byte_range.start.min(text.len()); - let end = elem.byte_range.end.min(text.len()); - if let Some(segment) = text.get(cursor..start) { - rebuilt.push_str(segment); - } - - let original = text.get(start..end).unwrap_or(""); - let placeholder = elem.placeholder(&text); - let replacement = placeholder - .and_then(|ph| mapping.get(ph)) - .map(String::as_str) - .unwrap_or(original); - - let elem_start = rebuilt.len(); - rebuilt.push_str(replacement); - let elem_end = rebuilt.len(); - - if let Some(remapped) = placeholder.and_then(|ph| mapping.get(ph)) { - elem.set_placeholder(Some(remapped.clone())); - } - elem.byte_range = (elem_start..elem_end).into(); - rebuilt_elements.push(elem); - cursor = end; - } - if let Some(segment) = text.get(cursor..) { - rebuilt.push_str(segment); - } - - UserMessage { - text: rebuilt, - local_images: remapped_images, - text_elements: rebuilt_elements, - } -} - -impl ChatWidget { - /// Synchronize the bottom-pane "task running" indicator with the current lifecycles. - /// - /// The bottom pane only has one running flag, but this module treats it as a derived state of - /// both the agent turn lifecycle and MCP startup lifecycle. - fn update_task_running_state(&mut self) { - self.bottom_pane - .set_task_running(self.agent_turn_running || self.mcp_startup_status.is_some()); - } - fn flush_answer_stream_with_separator(&mut self) { - if let Some(mut controller) = self.stream_controller.take() - && let Some(cell) = controller.finalize() - { - self.add_boxed_history(cell); - } - } - - /// Update the status indicator header and details. - /// - /// Passing `None` clears any existing details. - fn set_status(&mut self, header: String, details: Option) { - self.current_status_header = header.clone(); - self.bottom_pane.update_status(header, details); - } - - /// Convenience wrapper around [`Self::set_status`]; - /// updates the status indicator header and clears any existing details. - fn set_status_header(&mut self, header: String) { - self.set_status(header, None); - } - - fn restore_retry_status_header_if_present(&mut self) { - if let Some(header) = self.retry_status_header.take() { - self.set_status_header(header); - } - } - - // --- Small event handlers --- - fn on_session_configured(&mut self, event: codex_core::protocol::SessionConfiguredEvent) { - self.bottom_pane - .set_history_metadata(event.history_log_id, event.history_entry_count); - self.set_skills(None); - self.conversation_id = Some(event.session_id); - self.forked_from = event.forked_from_id; - self.current_rollout_path = Some(event.rollout_path.clone()); - let initial_messages = event.initial_messages.clone(); - let model_for_header = event.model.clone(); - self.session_header.set_model(&model_for_header); - // Only update stored collaboration settings when collaboration modes are disabled. - // When enabled, we preserve the selected variant (Plan/Pair/Execute/Custom) and its - // instructions as-is; the session configured event should not override it. - if !self.collaboration_modes_enabled() { - self.stored_collaboration_mode = self.stored_collaboration_mode.with_updates( - Some(model_for_header.clone()), - Some(event.reasoning_effort), - None, - ); - } - let session_info_cell = history_cell::new_session_info( - &self.config, - &model_for_header, - event, - self.show_welcome_banner, - self.collaboration_modes_enabled(), - self.stored_collaboration_mode.clone(), - ); - self.apply_session_info_cell(session_info_cell); - - if let Some(messages) = initial_messages { - self.replay_initial_messages(messages); - } - // Ask codex-core to enumerate custom prompts for this session. - self.submit_op(Op::ListCustomPrompts); - self.submit_op(Op::ListSkills { - cwds: Vec::new(), - force_reload: true, - }); - if let Some(user_message) = self.initial_user_message.take() { - self.submit_user_message(user_message); - } - if !self.suppress_session_configured_redraw { - self.request_redraw(); - } - } - - fn set_skills(&mut self, skills: Option>) { - self.bottom_pane.set_skills(skills); - } - - fn set_skills_from_response(&mut self, response: &ListSkillsResponseEvent) { - let skills = skills_for_cwd(&self.config.cwd, &response.skills); - self.set_skills(Some(skills)); - } - - pub(crate) fn open_feedback_note( - &mut self, - category: crate::app_event::FeedbackCategory, - include_logs: bool, - ) { - // Build a fresh snapshot at the time of opening the note overlay. - let snapshot = self.feedback.snapshot(self.conversation_id); - let rollout = if include_logs { - self.current_rollout_path.clone() - } else { - None - }; - let view = crate::bottom_pane::FeedbackNoteView::new( - category, - snapshot, - rollout, - self.app_event_tx.clone(), - include_logs, - ); - self.bottom_pane.show_view(Box::new(view)); - self.request_redraw(); - } - - pub(crate) fn open_feedback_consent(&mut self, category: crate::app_event::FeedbackCategory) { - let params = crate::bottom_pane::feedback_upload_consent_params( - self.app_event_tx.clone(), - category, - self.current_rollout_path.clone(), - ); - self.bottom_pane.show_selection_view(params); - self.request_redraw(); - } - - fn on_agent_message(&mut self, message: String) { - // If we have a stream_controller, then the final agent message is redundant and will be a - // duplicate of what has already been streamed. - if self.stream_controller.is_none() { - self.handle_streaming_delta(message); - } - self.flush_answer_stream_with_separator(); - self.handle_stream_finished(); - self.request_redraw(); - } - - fn on_agent_message_delta(&mut self, delta: String) { - self.handle_streaming_delta(delta); - } - - fn on_agent_reasoning_delta(&mut self, delta: String) { - // For reasoning deltas, do not stream to history. Accumulate the - // current reasoning block and extract the first bold element - // (between **/**) as the chunk header. Show this header as status. - self.reasoning_buffer.push_str(&delta); - - if let Some(header) = extract_first_bold(&self.reasoning_buffer) { - // Update the shimmer header to the extracted reasoning chunk header. - self.set_status_header(header); - } else { - // Fallback while we don't yet have a bold header: leave existing header as-is. - } - self.request_redraw(); - } - - fn on_agent_reasoning_final(&mut self) { - // At the end of a reasoning block, record transcript-only content. - self.full_reasoning_buffer.push_str(&self.reasoning_buffer); - if !self.full_reasoning_buffer.is_empty() { - let cell = - history_cell::new_reasoning_summary_block(self.full_reasoning_buffer.clone()); - self.add_boxed_history(cell); - } - self.reasoning_buffer.clear(); - self.full_reasoning_buffer.clear(); - self.request_redraw(); - } - - fn on_reasoning_section_break(&mut self) { - // Start a new reasoning block for header extraction and accumulate transcript. - self.full_reasoning_buffer.push_str(&self.reasoning_buffer); - self.full_reasoning_buffer.push_str("\n\n"); - self.reasoning_buffer.clear(); - } - - // Raw reasoning uses the same flow as summarized reasoning - - fn on_task_started(&mut self) { - self.agent_turn_running = true; - self.bottom_pane.clear_quit_shortcut_hint(); - self.quit_shortcut_expires_at = None; - self.quit_shortcut_key = None; - self.update_task_running_state(); - self.retry_status_header = None; - self.bottom_pane.set_interrupt_hint_visible(true); - self.set_status_header(String::from("Working")); - self.full_reasoning_buffer.clear(); - self.reasoning_buffer.clear(); - self.request_redraw(); - } - - fn on_task_complete(&mut self, last_agent_message: Option) { - // If a stream is currently active, finalize it. - self.flush_answer_stream_with_separator(); - // Mark task stopped and request redraw now that all content is in history. - self.agent_turn_running = false; - self.update_task_running_state(); - self.running_commands.clear(); - self.suppressed_exec_calls.clear(); - self.last_unified_wait = None; - self.request_redraw(); - - // If there is a queued user message, send exactly one now to begin the next turn. - self.maybe_send_next_queued_input(); - // Emit a notification when the turn completes (suppressed if focused). - self.notify(Notification::AgentTurnComplete { - response: last_agent_message.unwrap_or_default(), - }); - - self.maybe_show_pending_rate_limit_prompt(); - } - - pub(crate) fn set_token_info(&mut self, info: Option) { - match info { - Some(info) => self.apply_token_info(info), - None => { - self.bottom_pane.set_context_window(None, None); - self.token_info = None; - } - } - } - - fn apply_token_info(&mut self, info: TokenUsageInfo) { - let percent = self.context_remaining_percent(&info); - let used_tokens = self.context_used_tokens(&info, percent.is_some()); - self.bottom_pane.set_context_window(percent, used_tokens); - self.token_info = Some(info); - } - - fn context_remaining_percent(&self, info: &TokenUsageInfo) -> Option { - info.model_context_window.map(|window| { - info.last_token_usage - .percent_of_context_window_remaining(window) - }) - } - - fn context_used_tokens(&self, info: &TokenUsageInfo, percent_known: bool) -> Option { - if percent_known { - return None; - } - - Some(info.total_token_usage.tokens_in_context_window()) - } - - fn restore_pre_review_token_info(&mut self) { - if let Some(saved) = self.pre_review_token_info.take() { - match saved { - Some(info) => self.apply_token_info(info), - None => { - self.bottom_pane.set_context_window(None, None); - self.token_info = None; - } - } - } - } - - pub(crate) fn on_rate_limit_snapshot(&mut self, snapshot: Option) { - if let Some(mut snapshot) = snapshot { - if snapshot.credits.is_none() { - snapshot.credits = self - .rate_limit_snapshot - .as_ref() - .and_then(|display| display.credits.as_ref()) - .map(|credits| CreditsSnapshot { - has_credits: credits.has_credits, - unlimited: credits.unlimited, - balance: credits.balance.clone(), - }); - } - - self.plan_type = snapshot.plan_type.or(self.plan_type); - - let warnings = self.rate_limit_warnings.take_warnings( - snapshot - .secondary - .as_ref() - .map(|window| window.used_percent), - snapshot - .secondary - .as_ref() - .and_then(|window| window.window_minutes), - snapshot.primary.as_ref().map(|window| window.used_percent), - snapshot - .primary - .as_ref() - .and_then(|window| window.window_minutes), - ); - - let high_usage = snapshot - .secondary - .as_ref() - .map(|w| w.used_percent >= RATE_LIMIT_SWITCH_PROMPT_THRESHOLD) - .unwrap_or(false) - || snapshot - .primary - .as_ref() - .map(|w| w.used_percent >= RATE_LIMIT_SWITCH_PROMPT_THRESHOLD) - .unwrap_or(false); - - if high_usage - && !self.rate_limit_switch_prompt_hidden() - && self.current_model() != NUDGE_MODEL_SLUG - && !matches!( - self.rate_limit_switch_prompt, - RateLimitSwitchPromptState::Shown - ) - { - self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Pending; - } - - let display = crate::status::rate_limit_snapshot_display(&snapshot, Local::now()); - self.rate_limit_snapshot = Some(display); - - if !warnings.is_empty() { - for warning in warnings { - self.add_to_history(history_cell::new_warning_event(warning)); - } - self.request_redraw(); - } - } else { - self.rate_limit_snapshot = None; - } - } - /// Finalize any active exec as failed and stop/clear agent-turn UI state. - /// - /// This does not clear MCP startup tracking, because MCP startup can overlap with turn cleanup - /// and should continue to drive the bottom-pane running indicator while it is in progress. - fn finalize_turn(&mut self) { - // Ensure any spinner is replaced by a red ✗ and flushed into history. - self.finalize_active_cell_as_failed(); - // Reset running state and clear streaming buffers. - self.agent_turn_running = false; - self.update_task_running_state(); - self.running_commands.clear(); - self.suppressed_exec_calls.clear(); - self.last_unified_wait = None; - self.stream_controller = None; - self.maybe_show_pending_rate_limit_prompt(); - } - - fn on_error(&mut self, message: String) { - self.finalize_turn(); - self.add_to_history(history_cell::new_error_event(message)); - self.request_redraw(); - - // After an error ends the turn, try sending the next queued input. - self.maybe_send_next_queued_input(); - } - - fn on_warning(&mut self, message: impl Into) { - self.add_to_history(history_cell::new_warning_event(message.into())); - self.request_redraw(); - } - - fn on_mcp_startup_update(&mut self, ev: McpStartupUpdateEvent) { - let mut status = self.mcp_startup_status.take().unwrap_or_default(); - if let McpStartupStatus::Failed { error } = &ev.status { - self.on_warning(error); - } - status.insert(ev.server, ev.status); - self.mcp_startup_status = Some(status); - self.update_task_running_state(); - if let Some(current) = &self.mcp_startup_status { - let total = current.len(); - let mut starting: Vec<_> = current - .iter() - .filter_map(|(name, state)| { - if matches!(state, McpStartupStatus::Starting) { - Some(name) - } else { - None - } - }) - .collect(); - starting.sort(); - if let Some(first) = starting.first() { - let completed = total.saturating_sub(starting.len()); - let max_to_show = 3; - let mut to_show: Vec = starting - .iter() - .take(max_to_show) - .map(ToString::to_string) - .collect(); - if starting.len() > max_to_show { - to_show.push("…".to_string()); - } - let header = if total > 1 { - format!( - "Starting MCP servers ({completed}/{total}): {}", - to_show.join(", ") - ) - } else { - format!("Booting MCP server: {first}") - }; - self.set_status_header(header); - } - } - self.request_redraw(); - } - - fn on_mcp_startup_complete(&mut self, ev: McpStartupCompleteEvent) { - let mut parts = Vec::new(); - if !ev.failed.is_empty() { - let failed_servers: Vec<_> = ev.failed.iter().map(|f| f.server.clone()).collect(); - parts.push(format!("failed: {}", failed_servers.join(", "))); - } - if !ev.cancelled.is_empty() { - self.on_warning(format!( - "MCP startup interrupted. The following servers were not initialized: {}", - ev.cancelled.join(", ") - )); - } - if !parts.is_empty() { - self.on_warning(format!("MCP startup incomplete ({})", parts.join("; "))); - } - - self.mcp_startup_status = None; - self.update_task_running_state(); - self.maybe_send_next_queued_input(); - self.request_redraw(); - } - - /// Handle a turn aborted due to user interrupt (Esc). - /// When there are queued user messages, restore them into the composer - /// separated by newlines rather than auto‑submitting the next one. - fn on_interrupted_turn(&mut self, reason: TurnAbortReason) { - // Finalize, log a gentle prompt, and clear running state. - self.finalize_turn(); - - if reason != TurnAbortReason::ReviewEnded { - self.add_to_history(history_cell::new_error_event( - "Conversation interrupted - tell the model what to do differently. Something went wrong? Hit `/feedback` to report the issue.".to_owned(), - )); - } - - if let Some(combined) = self.drain_queued_messages_for_restore() { - let combined_local_image_paths = combined - .local_images - .iter() - .map(|img| img.path.clone()) - .collect(); - self.bottom_pane.set_composer_text( - combined.text, - combined.text_elements, - combined_local_image_paths, - ); - self.refresh_queued_user_messages(); - } - - self.request_redraw(); - } - - /// Merge queued drafts (plus the current composer state) into a single message for restore. - /// - /// Each queued draft numbers attachments from `[Image #1]`. When we concatenate drafts, we - /// must renumber placeholders in a stable order so the merged attachment list stays aligned - /// with the labels embedded in text. This helper drains the queue, remaps placeholders, and - /// fixes text element byte ranges as content is appended. Returns `None` when there is nothing - /// to restore. - fn drain_queued_messages_for_restore(&mut self) -> Option { - if self.queued_user_messages.is_empty() { - return None; - } - - let existing_message = UserMessage { - text: self.bottom_pane.composer_text(), - text_elements: self.bottom_pane.composer_text_elements(), - local_images: self.bottom_pane.composer_local_images(), - }; - - let mut to_merge: Vec = self.queued_user_messages.drain(..).collect(); - if !existing_message.text.is_empty() || !existing_message.local_images.is_empty() { - to_merge.push(existing_message); - } - - let mut combined = UserMessage { - text: String::new(), - text_elements: Vec::new(), - local_images: Vec::new(), - }; - let mut combined_offset = 0usize; - let mut next_image_label = 1usize; - - for (idx, message) in to_merge.into_iter().enumerate() { - if idx > 0 { - combined.text.push('\n'); - combined_offset += 1; - } - let message = remap_placeholders_for_message(message, &mut next_image_label); - let base = combined_offset; - combined.text.push_str(&message.text); - combined_offset += message.text.len(); - combined - .text_elements - .extend(message.text_elements.into_iter().map(|mut elem| { - elem.byte_range.start += base; - elem.byte_range.end += base; - elem - })); - combined.local_images.extend(message.local_images); - } - - Some(combined) - } - - fn on_plan_update(&mut self, update: UpdatePlanArgs) { - self.add_to_history(history_cell::new_plan_update(update)); - } - - fn on_exec_approval_request(&mut self, id: String, ev: ExecApprovalRequestEvent) { - let id2 = id.clone(); - let ev2 = ev.clone(); - self.defer_or_handle( - |q| q.push_exec_approval(id, ev), - |s| s.handle_exec_approval_now(id2, ev2), - ); - } - - fn on_apply_patch_approval_request(&mut self, id: String, ev: ApplyPatchApprovalRequestEvent) { - let id2 = id.clone(); - let ev2 = ev.clone(); - self.defer_or_handle( - |q| q.push_apply_patch_approval(id, ev), - |s| s.handle_apply_patch_approval_now(id2, ev2), - ); - } - - fn on_elicitation_request(&mut self, ev: ElicitationRequestEvent) { - let ev2 = ev.clone(); - self.defer_or_handle( - |q| q.push_elicitation(ev), - |s| s.handle_elicitation_request_now(ev2), - ); - } - - fn on_exec_command_begin(&mut self, ev: ExecCommandBeginEvent) { - self.flush_answer_stream_with_separator(); - let ev2 = ev.clone(); - self.defer_or_handle(|q| q.push_exec_begin(ev), |s| s.handle_exec_begin_now(ev2)); - } - - fn on_exec_command_output_delta(&mut self, ev: ExecCommandOutputDeltaEvent) { - let Some(cell) = self - .active_cell - .as_mut() - .and_then(|c| c.as_any_mut().downcast_mut::()) - else { - return; - }; - - if cell.append_output(&ev.call_id, std::str::from_utf8(&ev.chunk).unwrap_or("")) { - self.bump_active_cell_revision(); - self.request_redraw(); - } - } - - fn on_terminal_interaction(&mut self, _ev: TerminalInteractionEvent) { - // TODO: Handle once design is ready - } - - fn on_patch_apply_begin(&mut self, event: PatchApplyBeginEvent) { - self.add_to_history(history_cell::new_patch_event( - event.changes, - &self.config.cwd, - )); - } - - fn on_view_image_tool_call(&mut self, event: ViewImageToolCallEvent) { - self.flush_answer_stream_with_separator(); - self.add_to_history(history_cell::new_view_image_tool_call( - event.path, - &self.config.cwd, - )); - self.request_redraw(); - } - - fn on_patch_apply_end(&mut self, event: codex_core::protocol::PatchApplyEndEvent) { - let ev2 = event.clone(); - self.defer_or_handle( - |q| q.push_patch_end(event), - |s| s.handle_patch_apply_end_now(ev2), - ); - } - - fn on_exec_command_end(&mut self, ev: ExecCommandEndEvent) { - let ev2 = ev.clone(); - self.defer_or_handle(|q| q.push_exec_end(ev), |s| s.handle_exec_end_now(ev2)); - } - - fn on_mcp_tool_call_begin(&mut self, ev: McpToolCallBeginEvent) { - let ev2 = ev.clone(); - self.defer_or_handle(|q| q.push_mcp_begin(ev), |s| s.handle_mcp_begin_now(ev2)); - } - - fn on_mcp_tool_call_end(&mut self, ev: McpToolCallEndEvent) { - let ev2 = ev.clone(); - self.defer_or_handle(|q| q.push_mcp_end(ev), |s| s.handle_mcp_end_now(ev2)); - } - - fn on_web_search_begin(&mut self, _ev: WebSearchBeginEvent) { - self.flush_answer_stream_with_separator(); - } - - fn on_web_search_end(&mut self, ev: WebSearchEndEvent) { - self.flush_answer_stream_with_separator(); - self.add_to_history(history_cell::new_web_search_call(ev.query)); - } - - fn on_collab_event(&mut self, cell: PlainHistoryCell) { - self.flush_answer_stream_with_separator(); - self.add_to_history(cell); - self.request_redraw(); - } - - fn on_get_history_entry_response( - &mut self, - event: codex_core::protocol::GetHistoryEntryResponseEvent, - ) { - let codex_core::protocol::GetHistoryEntryResponseEvent { - offset, - log_id, - entry, - } = event; - self.bottom_pane - .on_history_entry_response(log_id, offset, entry.map(|e| e.text)); - } - - fn on_shutdown_complete(&mut self) { - self.request_immediate_exit(); - } - - fn on_turn_diff(&mut self, unified_diff: String) { - debug!("TurnDiffEvent: {unified_diff}"); - } - - fn on_deprecation_notice(&mut self, event: DeprecationNoticeEvent) { - let DeprecationNoticeEvent { summary, details } = event; - self.add_to_history(history_cell::new_deprecation_notice(summary, details)); - self.request_redraw(); - } - - fn on_background_event(&mut self, message: String) { - debug!("BackgroundEvent: {message}"); - self.bottom_pane.ensure_status_indicator(); - self.bottom_pane.set_interrupt_hint_visible(true); - self.set_status_header(message); - } - - fn on_undo_started(&mut self, event: UndoStartedEvent) { - self.bottom_pane.ensure_status_indicator(); - self.bottom_pane.set_interrupt_hint_visible(false); - let message = event - .message - .unwrap_or_else(|| "Undo in progress...".to_string()); - self.set_status_header(message); - } - - fn on_undo_completed(&mut self, event: UndoCompletedEvent) { - let UndoCompletedEvent { success, message } = event; - self.bottom_pane.hide_status_indicator(); - let message = message.unwrap_or_else(|| { - if success { - "Undo completed successfully.".to_string() - } else { - "Undo failed.".to_string() - } - }); - if success { - self.add_info_message(message, None); - } else { - self.add_error_message(message); - } - } - - fn on_stream_error(&mut self, message: String, additional_details: Option) { - if self.retry_status_header.is_none() { - self.retry_status_header = Some(self.current_status_header.clone()); - } - self.set_status(message, additional_details); - } - - /// Periodic tick to commit at most one queued line to history with a small delay, - /// animating the output. - pub(crate) fn on_commit_tick(&mut self) { - if let Some(controller) = self.stream_controller.as_mut() { - let (cell, is_idle) = controller.on_commit_tick(); - if let Some(cell) = cell { - self.bottom_pane.hide_status_indicator(); - self.add_boxed_history(cell); - self.request_redraw(); - } - if is_idle { - self.app_event_tx.send(AppEvent::StopCommitAnimation); - } - } - } - - fn flush_interrupt_queue(&mut self) { - let mut mgr = std::mem::take(&mut self.interrupts); - mgr.flush_all(self); - self.interrupts = mgr; - } - - #[inline] - fn defer_or_handle( - &mut self, - push: impl FnOnce(&mut InterruptManager), - handle: impl FnOnce(&mut Self), - ) { - // Preserve deterministic FIFO across queued interrupts: once anything - // is queued due to an active write cycle, continue queueing until the - // queue is flushed to avoid reordering (e.g., ExecEnd before ExecBegin). - if self.stream_controller.is_some() || !self.interrupts.is_empty() { - push(&mut self.interrupts); - } else { - handle(self); - } - } - - fn handle_stream_finished(&mut self) { - if self.task_complete_pending { - self.bottom_pane.hide_status_indicator(); - self.task_complete_pending = false; - } - // A completed stream indicates non-exec content was just inserted. - self.flush_interrupt_queue(); - } - - #[inline] - fn handle_streaming_delta(&mut self, delta: String) { - // Before streaming agent content, flush any active exec cell group. - let mut needs_redraw = self.active_cell.is_some(); - self.flush_active_cell(); - - if self.stream_controller.is_none() { - // If the previous turn inserted non-stream history (exec output, patch status, MCP - // calls), render a separator before starting the next streamed assistant message. - if self.needs_final_message_separator && self.had_work_activity { - let elapsed_seconds = self - .bottom_pane - .status_widget() - .map(super::status_indicator_widget::StatusIndicatorWidget::elapsed_seconds) - .map(|current| self.worked_elapsed_from(current)); - self.add_to_history(history_cell::FinalMessageSeparator::new(elapsed_seconds)); - self.needs_final_message_separator = false; - self.had_work_activity = false; - needs_redraw = true; - } else if self.needs_final_message_separator { - // Reset the flag even if we don't show separator (no work was done) - self.needs_final_message_separator = false; - } - // Streaming must not capture the current viewport width: width-derived wraps are - // applied later, at render time, so the transcript can reflow on resize. - self.stream_controller = Some(StreamController::new()); - } - if let Some(controller) = self.stream_controller.as_mut() - && controller.push(&delta) - { - self.app_event_tx.send(AppEvent::StartCommitAnimation); - } - if needs_redraw { - self.request_redraw(); - } - } - - fn worked_elapsed_from(&mut self, current_elapsed: u64) -> u64 { - let baseline = match self.last_separator_elapsed_secs { - Some(last) if current_elapsed < last => 0, - Some(last) => last, - None => 0, - }; - let elapsed = current_elapsed.saturating_sub(baseline); - self.last_separator_elapsed_secs = Some(current_elapsed); - elapsed - } - - pub(crate) fn handle_exec_end_now(&mut self, ev: ExecCommandEndEvent) { - let running = self.running_commands.remove(&ev.call_id); - if self.suppressed_exec_calls.remove(&ev.call_id) { - return; - } - let (command, parsed, source) = match running { - Some(rc) => (rc.command, rc.parsed_cmd, rc.source), - None => (ev.command.clone(), ev.parsed_cmd.clone(), ev.source), - }; - let is_unified_exec_interaction = - matches!(source, ExecCommandSource::UnifiedExecInteraction); - - let needs_new = self - .active_cell - .as_ref() - .map(|cell| cell.as_any().downcast_ref::().is_none()) - .unwrap_or(true); - if needs_new { - self.flush_active_cell(); - self.active_cell = Some(Box::new(new_active_exec_command( - ev.call_id.clone(), - command, - parsed, - source, - ev.interaction_input.clone(), - self.config.animations, - ))); - } - - if let Some(cell) = self - .active_cell - .as_mut() - .and_then(|c| c.as_any_mut().downcast_mut::()) - { - let output = if is_unified_exec_interaction { - CommandOutput { - exit_code: ev.exit_code, - formatted_output: String::new(), - aggregated_output: String::new(), - } - } else { - CommandOutput { - exit_code: ev.exit_code, - formatted_output: ev.formatted_output.clone(), - aggregated_output: ev.aggregated_output.clone(), - } - }; - cell.complete_call(&ev.call_id, output, ev.duration); - if cell.should_flush() { - self.flush_active_cell(); - } else { - self.bump_active_cell_revision(); - self.request_redraw(); - } - } - // Mark that actual work was done (command executed) - self.had_work_activity = true; - } - - pub(crate) fn handle_patch_apply_end_now( - &mut self, - event: codex_core::protocol::PatchApplyEndEvent, - ) { - // If the patch was successful, just let the "Edited" block stand. - // Otherwise, add a failure block. - if !event.success { - self.add_to_history(history_cell::new_patch_apply_failure(event.stderr)); - } - // Mark that actual work was done (patch applied) - self.had_work_activity = true; - } - - pub(crate) fn handle_exec_approval_now(&mut self, id: String, ev: ExecApprovalRequestEvent) { - self.flush_answer_stream_with_separator(); - let command = shlex::try_join(ev.command.iter().map(String::as_str)) - .unwrap_or_else(|_| ev.command.join(" ")); - self.notify(Notification::ExecApprovalRequested { command }); - - let request = ApprovalRequest::Exec { - id, - command: ev.command, - reason: ev.reason, - proposed_execpolicy_amendment: ev.proposed_execpolicy_amendment, - }; - self.bottom_pane - .push_approval_request(request, &self.config.features); - self.request_redraw(); - } - - pub(crate) fn handle_apply_patch_approval_now( - &mut self, - id: String, - ev: ApplyPatchApprovalRequestEvent, - ) { - self.flush_answer_stream_with_separator(); - - let request = ApprovalRequest::ApplyPatch { - id, - reason: ev.reason, - changes: ev.changes.clone(), - cwd: self.config.cwd.clone(), - }; - self.bottom_pane - .push_approval_request(request, &self.config.features); - self.request_redraw(); - self.notify(Notification::EditApprovalRequested { - cwd: self.config.cwd.clone(), - changes: ev.changes.keys().cloned().collect(), - }); - } - - pub(crate) fn handle_elicitation_request_now(&mut self, ev: ElicitationRequestEvent) { - self.flush_answer_stream_with_separator(); - - self.notify(Notification::ElicitationRequested { - server_name: ev.server_name.clone(), - }); - - let request = ApprovalRequest::McpElicitation { - server_name: ev.server_name, - request_id: ev.id, - message: ev.message, - }; - self.bottom_pane - .push_approval_request(request, &self.config.features); - self.request_redraw(); - } - - pub(crate) fn handle_exec_begin_now(&mut self, ev: ExecCommandBeginEvent) { - // Ensure the status indicator is visible while the command runs. - self.running_commands.insert( - ev.call_id.clone(), - RunningCommand { - command: ev.command.clone(), - parsed_cmd: ev.parsed_cmd.clone(), - source: ev.source, - }, - ); - let is_wait_interaction = matches!(ev.source, ExecCommandSource::UnifiedExecInteraction) - && ev - .interaction_input - .as_deref() - .map(str::is_empty) - .unwrap_or(true); - let command_display = ev.command.join(" "); - let should_suppress_unified_wait = is_wait_interaction - && self - .last_unified_wait - .as_ref() - .is_some_and(|wait| wait.is_duplicate(&command_display)); - if is_wait_interaction { - self.last_unified_wait = Some(UnifiedExecWaitState::new(command_display)); - } else { - self.last_unified_wait = None; - } - if should_suppress_unified_wait { - self.suppressed_exec_calls.insert(ev.call_id); - return; - } - let interaction_input = ev.interaction_input.clone(); - if let Some(cell) = self - .active_cell - .as_mut() - .and_then(|c| c.as_any_mut().downcast_mut::()) - && let Some(new_exec) = cell.with_added_call( - ev.call_id.clone(), - ev.command.clone(), - ev.parsed_cmd.clone(), - ev.source, - interaction_input.clone(), - ) - { - *cell = new_exec; - self.bump_active_cell_revision(); - } else { - self.flush_active_cell(); - - self.active_cell = Some(Box::new(new_active_exec_command( - ev.call_id.clone(), - ev.command.clone(), - ev.parsed_cmd, - ev.source, - interaction_input, - self.config.animations, - ))); - self.bump_active_cell_revision(); - } - - self.request_redraw(); - } - - pub(crate) fn handle_mcp_begin_now(&mut self, ev: McpToolCallBeginEvent) { - self.flush_answer_stream_with_separator(); - self.flush_active_cell(); - self.active_cell = Some(Box::new(history_cell::new_active_mcp_tool_call( - ev.call_id, - ev.invocation, - self.config.animations, - ))); - self.bump_active_cell_revision(); - self.request_redraw(); - } - pub(crate) fn handle_mcp_end_now(&mut self, ev: McpToolCallEndEvent) { - self.flush_answer_stream_with_separator(); - - let McpToolCallEndEvent { - call_id, - invocation, - duration, - result, - } = ev; - - let extra_cell = match self - .active_cell - .as_mut() - .and_then(|cell| cell.as_any_mut().downcast_mut::()) - { - Some(cell) if cell.call_id() == call_id => cell.complete(duration, result), - _ => { - self.flush_active_cell(); - let mut cell = history_cell::new_active_mcp_tool_call( - call_id, - invocation, - self.config.animations, - ); - let extra_cell = cell.complete(duration, result); - self.active_cell = Some(Box::new(cell)); - extra_cell - } - }; - - self.flush_active_cell(); - if let Some(extra) = extra_cell { - self.add_boxed_history(extra); - } - // Mark that actual work was done (MCP tool call) - self.had_work_activity = true; - } - - pub(crate) fn new(common: ChatWidgetInit, thread_manager: Arc) -> Self { - let ChatWidgetInit { - config, - frame_requester, - app_event_tx, - initial_user_message, - enhanced_keys_supported, - auth_manager, - models_manager, - feedback, - is_first_run, - model, - otel_manager, - } = common; - let model = model.filter(|m| !m.trim().is_empty()); - let mut config = config; - config.model = model.clone(); - let mut rng = rand::rng(); - let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string(); - let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), thread_manager); - - let model_for_header = model.unwrap_or_else(|| DEFAULT_MODEL_DISPLAY_NAME.to_string()); - let stored_collaboration_mode = if config.features.enabled(Feature::CollaborationModes) { - collaboration_modes::default_mode(models_manager.as_ref()).unwrap_or_else(|| { - CollaborationMode::Custom(Settings { - model: model_for_header.clone(), - reasoning_effort: None, - developer_instructions: None, - }) - }) - } else { - CollaborationMode::Custom(Settings { - model: model_for_header.clone(), - reasoning_effort: None, - developer_instructions: None, - }) - }; - let active_cell = Some(Self::placeholder_session_header_cell( - &config, - config.features.enabled(Feature::CollaborationModes), - stored_collaboration_mode.clone(), - )); - - let mut widget = Self { - app_event_tx: app_event_tx.clone(), - frame_requester: frame_requester.clone(), - codex_op_tx, - bottom_pane: BottomPane::new(BottomPaneParams { - frame_requester, - app_event_tx, - has_input_focus: true, - enhanced_keys_supported, - placeholder_text: placeholder, - disable_paste_burst: config.disable_paste_burst, - animations_enabled: config.animations, - skills: None, - }), - active_cell, - active_cell_revision: 0, - config, - stored_collaboration_mode, - auth_manager, - models_manager, - otel_manager, - session_header: SessionHeader::new(model_for_header), - initial_user_message, - token_info: None, - rate_limit_snapshot: None, - plan_type: None, - rate_limit_warnings: RateLimitWarningState::default(), - rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), - rate_limit_poller: None, - stream_controller: None, - running_commands: HashMap::new(), - suppressed_exec_calls: HashSet::new(), - last_unified_wait: None, - task_complete_pending: false, - agent_turn_running: false, - mcp_startup_status: None, - interrupts: InterruptManager::new(), - reasoning_buffer: String::new(), - full_reasoning_buffer: String::new(), - current_status_header: String::from("Working"), - retry_status_header: None, - conversation_id: None, - forked_from: None, - queued_user_messages: VecDeque::new(), - show_welcome_banner: is_first_run, - suppress_session_configured_redraw: false, - pending_notification: None, - quit_shortcut_expires_at: None, - quit_shortcut_key: None, - is_review_mode: false, - pre_review_token_info: None, - needs_final_message_separator: false, - had_work_activity: false, - last_separator_elapsed_secs: None, - last_rendered_width: std::cell::Cell::new(None), - feedback, - current_rollout_path: None, - }; - - widget.prefetch_rate_limits(); - widget - .bottom_pane - .set_steer_enabled(widget.config.features.enabled(Feature::Steer)); - widget.bottom_pane.set_collaboration_modes_enabled( - widget.config.features.enabled(Feature::CollaborationModes), - ); - - widget - } - - /// Create a ChatWidget attached to an existing conversation (e.g., a fork). - pub(crate) fn new_from_existing( - common: ChatWidgetInit, - conversation: std::sync::Arc, - session_configured: codex_core::protocol::SessionConfiguredEvent, - ) -> Self { - let ChatWidgetInit { - config, - frame_requester, - app_event_tx, - initial_user_message, - enhanced_keys_supported, - auth_manager, - models_manager, - feedback, - model, - otel_manager, - .. - } = common; - let model = model.filter(|m| !m.trim().is_empty()); - let config = config; - let mut rng = rand::rng(); - let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string(); - - let header_model = model.unwrap_or_else(|| session_configured.model.clone()); - - let codex_op_tx = - spawn_agent_from_existing(conversation, session_configured, app_event_tx.clone()); - - let stored_collaboration_mode = if config.features.enabled(Feature::CollaborationModes) { - collaboration_modes::default_mode(models_manager.as_ref()).unwrap_or_else(|| { - CollaborationMode::Custom(Settings { - model: header_model.clone(), - reasoning_effort: None, - developer_instructions: None, - }) - }) - } else { - CollaborationMode::Custom(Settings { - model: header_model.clone(), - reasoning_effort: None, - developer_instructions: None, - }) - }; - - let mut widget = Self { - app_event_tx: app_event_tx.clone(), - frame_requester: frame_requester.clone(), - codex_op_tx, - bottom_pane: BottomPane::new(BottomPaneParams { - frame_requester, - app_event_tx, - has_input_focus: true, - enhanced_keys_supported, - placeholder_text: placeholder, - disable_paste_burst: config.disable_paste_burst, - animations_enabled: config.animations, - skills: None, - }), - active_cell: None, - active_cell_revision: 0, - config, - stored_collaboration_mode, - auth_manager, - models_manager, - otel_manager, - session_header: SessionHeader::new(header_model), - initial_user_message, - token_info: None, - rate_limit_snapshot: None, - plan_type: None, - rate_limit_warnings: RateLimitWarningState::default(), - rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), - rate_limit_poller: None, - stream_controller: None, - running_commands: HashMap::new(), - suppressed_exec_calls: HashSet::new(), - last_unified_wait: None, - task_complete_pending: false, - agent_turn_running: false, - mcp_startup_status: None, - interrupts: InterruptManager::new(), - reasoning_buffer: String::new(), - full_reasoning_buffer: String::new(), - current_status_header: String::from("Working"), - retry_status_header: None, - conversation_id: None, - forked_from: None, - queued_user_messages: VecDeque::new(), - show_welcome_banner: false, - suppress_session_configured_redraw: true, - pending_notification: None, - quit_shortcut_expires_at: None, - quit_shortcut_key: None, - is_review_mode: false, - pre_review_token_info: None, - needs_final_message_separator: false, - had_work_activity: false, - last_separator_elapsed_secs: None, - last_rendered_width: std::cell::Cell::new(None), - feedback, - current_rollout_path: None, - }; - - widget.prefetch_rate_limits(); - widget - .bottom_pane - .set_steer_enabled(widget.config.features.enabled(Feature::Steer)); - widget.bottom_pane.set_collaboration_modes_enabled( - widget.config.features.enabled(Feature::CollaborationModes), - ); - - widget - } - - pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) { - match key_event { - KeyEvent { - code: KeyCode::Char(c), - modifiers, - kind: KeyEventKind::Press, - .. - } if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'c') => { - self.on_ctrl_c(); - return; - } - KeyEvent { - code: KeyCode::Char(c), - modifiers, - kind: KeyEventKind::Press, - .. - } if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'d') => { - if self.on_ctrl_d() { - return; - } - self.bottom_pane.clear_quit_shortcut_hint(); - self.quit_shortcut_expires_at = None; - self.quit_shortcut_key = None; - } - KeyEvent { - code: KeyCode::Char(c), - modifiers, - kind: KeyEventKind::Press, - .. - } if modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) - && c.eq_ignore_ascii_case(&'v') => - { - match paste_image_to_temp_png() { - Ok((path, info)) => { - tracing::debug!( - "pasted image size={}x{} format={}", - info.width, - info.height, - info.encoded_format.label() - ); - self.attach_image(path); - } - Err(err) => { - tracing::warn!("failed to paste image: {err}"); - self.add_to_history(history_cell::new_error_event(format!( - "Failed to paste image: {err}", - ))); - } - } - return; - } - other if other.kind == KeyEventKind::Press => { - self.bottom_pane.clear_quit_shortcut_hint(); - self.quit_shortcut_expires_at = None; - self.quit_shortcut_key = None; - } - _ => {} - } - - match key_event { - KeyEvent { - code: KeyCode::BackTab, - kind: KeyEventKind::Press, - .. - } if self.collaboration_modes_enabled() - && !self.bottom_pane.is_task_running() - && self.bottom_pane.no_modal_or_popup_active() => - { - self.cycle_collaboration_mode(); - } - KeyEvent { - code: KeyCode::Up, - modifiers: KeyModifiers::ALT, - kind: KeyEventKind::Press, - .. - } if !self.queued_user_messages.is_empty() => { - // Prefer the most recently queued item. - if let Some(user_message) = self.queued_user_messages.pop_back() { - let local_image_paths = user_message - .local_images - .iter() - .map(|img| img.path.clone()) - .collect(); - self.bottom_pane.set_composer_text( - user_message.text, - user_message.text_elements, - local_image_paths, - ); - self.refresh_queued_user_messages(); - self.request_redraw(); - } - } - _ => match self.bottom_pane.handle_key_event(key_event) { - InputResult::Submitted { - text, - text_elements, - } => { - let user_message = UserMessage { - text, - local_images: self - .bottom_pane - .take_recent_submission_images_with_placeholders(), - text_elements, - }; - if self.is_session_configured() { - // Submitted is only emitted when steer is enabled (Enter sends immediately). - // Reset any reasoning header only when we are actually submitting a turn. - self.reasoning_buffer.clear(); - self.full_reasoning_buffer.clear(); - self.set_status_header(String::from("Working")); - self.submit_user_message(user_message); - } else { - self.queue_user_message(user_message); - } - } - InputResult::Queued { - text, - text_elements, - } => { - let user_message = UserMessage { - text, - local_images: self - .bottom_pane - .take_recent_submission_images_with_placeholders(), - text_elements, - }; - self.queue_user_message(user_message); - } - InputResult::Command(cmd) => { - self.dispatch_command(cmd); - } - InputResult::CommandWithArgs(cmd, args) => { - self.dispatch_command_with_args(cmd, args); - } - InputResult::None => {} - }, - } - } - - pub(crate) fn attach_image(&mut self, path: PathBuf) { - tracing::info!("attach_image path={path:?}"); - self.bottom_pane.attach_image(path); - self.request_redraw(); - } - - fn dispatch_command(&mut self, cmd: SlashCommand) { - if !cmd.available_during_task() && self.bottom_pane.is_task_running() { - let message = format!( - "'/{}' is disabled while a task is in progress.", - cmd.command() - ); - self.add_to_history(history_cell::new_error_event(message)); - self.request_redraw(); - return; - } - match cmd { - SlashCommand::Feedback => { - if !self.config.feedback_enabled { - let params = crate::bottom_pane::feedback_disabled_params(); - self.bottom_pane.show_selection_view(params); - self.request_redraw(); - return; - } - // Step 1: pick a category (UI built in feedback_view) - let params = - crate::bottom_pane::feedback_selection_params(self.app_event_tx.clone()); - self.bottom_pane.show_selection_view(params); - self.request_redraw(); - } - SlashCommand::New => { - self.app_event_tx.send(AppEvent::NewSession); - } - SlashCommand::Resume => { - self.app_event_tx.send(AppEvent::OpenResumePicker); - } - SlashCommand::Fork => { - self.app_event_tx.send(AppEvent::ForkCurrentSession); - } - SlashCommand::Init => { - let init_target = self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME); - if init_target.exists() { - let message = format!( - "{DEFAULT_PROJECT_DOC_FILENAME} already exists here. Skipping /init to avoid overwriting it." - ); - self.add_info_message(message, None); - return; - } - const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md"); - self.submit_user_message(INIT_PROMPT.to_string().into()); - } - SlashCommand::Compact => { - self.clear_token_usage(); - self.app_event_tx.send(AppEvent::CodexOp(Op::Compact)); - } - SlashCommand::Review => { - self.open_review_popup(); - } - SlashCommand::Model => { - self.open_model_popup(); - } - SlashCommand::Collab => { - if self.collaboration_modes_enabled() { - self.open_collaboration_modes_popup(); - } - } - SlashCommand::Approvals => { - self.open_approvals_popup(); - } - SlashCommand::ElevateSandbox => { - #[cfg(target_os = "windows")] - { - let windows_degraded_sandbox_enabled = codex_core::get_platform_sandbox() - .is_some() - && !codex_core::is_windows_elevated_sandbox_enabled(); - if !windows_degraded_sandbox_enabled - || !codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED - { - // This command should not be visible/recognized outside degraded mode, - // but guard anyway in case something dispatches it directly. - return; - } - - let Some(preset) = builtin_approval_presets() - .into_iter() - .find(|preset| preset.id == "auto") - else { - // Avoid panicking in interactive UI; treat this as a recoverable - // internal error. - self.add_error_message( - "Internal error: missing the 'auto' approval preset.".to_string(), - ); - return; - }; - - if let Err(err) = self.config.approval_policy.can_set(&preset.approval) { - self.add_error_message(err.to_string()); - return; - } - - self.otel_manager.counter( - "codex.windows_sandbox.setup_elevated_sandbox_command", - 1, - &[], - ); - self.app_event_tx - .send(AppEvent::BeginWindowsSandboxElevatedSetup { preset }); - } - #[cfg(not(target_os = "windows"))] - { - let _ = &self.otel_manager; - // Not supported; on non-Windows this command should never be reachable. - }; - } - SlashCommand::Experimental => { - self.open_experimental_popup(); - } - SlashCommand::Quit | SlashCommand::Exit => { - self.request_quit_without_confirmation(); - } - SlashCommand::Logout => { - if let Err(e) = codex_core::auth::logout( - &self.config.codex_home, - self.config.cli_auth_credentials_store_mode, - ) { - tracing::error!("failed to logout: {e}"); - } - self.request_quit_without_confirmation(); - } - // SlashCommand::Undo => { - // self.app_event_tx.send(AppEvent::CodexOp(Op::Undo)); - // } - SlashCommand::Diff => { - self.add_diff_in_progress(); - let tx = self.app_event_tx.clone(); - tokio::spawn(async move { - let text = match get_git_diff().await { - Ok((is_git_repo, diff_text)) => { - if is_git_repo { - diff_text - } else { - "`/diff` — _not inside a git repository_".to_string() - } - } - Err(e) => format!("Failed to compute diff: {e}"), - }; - tx.send(AppEvent::DiffResult(text)); - }); - } - SlashCommand::Mention => { - self.insert_str("@"); - } - SlashCommand::Skills => { - self.insert_str("$"); - } - SlashCommand::Status => { - self.add_status_output(); - } - SlashCommand::Mcp => { - self.add_mcp_output(); - } - SlashCommand::Rollout => { - if let Some(path) = self.rollout_path() { - self.add_info_message( - format!("Current rollout path: {}", path.display()), - None, - ); - } else { - self.add_info_message("Rollout path is not available yet.".to_string(), None); - } - } - SlashCommand::TestApproval => { - use codex_core::protocol::EventMsg; - use std::collections::HashMap; - - use codex_core::protocol::ApplyPatchApprovalRequestEvent; - use codex_core::protocol::FileChange; - - self.app_event_tx.send(AppEvent::CodexEvent(Event { - id: "1".to_string(), - // msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { - // call_id: "1".to_string(), - // command: vec!["git".into(), "apply".into()], - // cwd: self.config.cwd.clone(), - // reason: Some("test".to_string()), - // }), - msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { - call_id: "1".to_string(), - turn_id: "turn-1".to_string(), - changes: HashMap::from([ - ( - PathBuf::from("/tmp/test.txt"), - FileChange::Add { - content: "test".to_string(), - }, - ), - ( - PathBuf::from("/tmp/test2.txt"), - FileChange::Update { - unified_diff: "+test\n-test2".to_string(), - move_path: None, - }, - ), - ]), - reason: None, - grant_root: Some(PathBuf::from("/tmp")), - }), - })); - } - } - } - - fn dispatch_command_with_args(&mut self, cmd: SlashCommand, args: String) { - if !cmd.available_during_task() && self.bottom_pane.is_task_running() { - let message = format!( - "'/{}' is disabled while a task is in progress.", - cmd.command() - ); - self.add_to_history(history_cell::new_error_event(message)); - self.request_redraw(); - return; - } - - let trimmed = args.trim(); - match cmd { - SlashCommand::Review if !trimmed.is_empty() => { - self.submit_op(Op::Review { - review_request: ReviewRequest { - target: ReviewTarget::Custom { - instructions: trimmed.to_string(), - }, - user_facing_hint: None, - }, - }); - } - SlashCommand::Collab => { - if !self.collaboration_modes_enabled() { - return; - } - - let _ = trimmed; - self.open_collaboration_modes_popup(); - } - _ => self.dispatch_command(cmd), - } - } - - pub(crate) fn handle_paste(&mut self, text: String) { - self.bottom_pane.handle_paste(text); - } - - // Returns true if caller should skip rendering this frame (a future frame is scheduled). - pub(crate) fn handle_paste_burst_tick(&mut self, frame_requester: FrameRequester) -> bool { - if self.bottom_pane.flush_paste_burst_if_due() { - // A paste just flushed; request an immediate redraw and skip this frame. - self.request_redraw(); - true - } else if self.bottom_pane.is_in_paste_burst() { - // While capturing a burst, schedule a follow-up tick and skip this frame - // to avoid redundant renders between ticks. - frame_requester.schedule_frame_in( - crate::bottom_pane::ChatComposer::recommended_paste_flush_delay(), - ); - true - } else { - false - } - } - - fn flush_active_cell(&mut self) { - if let Some(active) = self.active_cell.take() { - self.needs_final_message_separator = true; - self.app_event_tx.send(AppEvent::InsertHistoryCell(active)); - } - } - - fn add_to_history(&mut self, cell: impl HistoryCell + 'static) { - self.add_boxed_history(Box::new(cell)); - } - - fn add_boxed_history(&mut self, cell: Box) { - // Keep the placeholder session header as the active cell until real session info arrives, - // so we can merge headers instead of committing a duplicate box to history. - let keep_placeholder_header_active = !self.is_session_configured() - && self - .active_cell - .as_ref() - .is_some_and(|c| c.as_any().is::()); - - if !keep_placeholder_header_active && !cell.display_lines(u16::MAX).is_empty() { - // Only break exec grouping if the cell renders visible lines. - self.flush_active_cell(); - self.needs_final_message_separator = true; - } - self.app_event_tx.send(AppEvent::InsertHistoryCell(cell)); - } - - #[allow(dead_code)] // Used in tests - fn queue_user_message(&mut self, user_message: UserMessage) { - if !self.is_session_configured() || self.bottom_pane.is_task_running() { - self.queued_user_messages.push_back(user_message); - self.refresh_queued_user_messages(); - } else { - self.submit_user_message(user_message); - } - } - - fn submit_user_message(&mut self, user_message: UserMessage) { - if !self.is_session_configured() { - tracing::warn!("cannot submit user message before session is configured; queueing"); - self.queued_user_messages.push_front(user_message); - self.refresh_queued_user_messages(); - return; - } - - let UserMessage { - text, - local_images, - text_elements, - } = user_message; - if text.is_empty() && local_images.is_empty() { - return; - } - - let mut items: Vec = Vec::new(); - - // Special-case: "!cmd" executes a local shell command instead of sending to the model. - if let Some(stripped) = text.strip_prefix('!') { - let cmd = stripped.trim(); - if cmd.is_empty() { - self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::new_info_event( - USER_SHELL_COMMAND_HELP_TITLE.to_string(), - Some(USER_SHELL_COMMAND_HELP_HINT.to_string()), - ), - ))); - return; - } - self.submit_op(Op::RunUserShellCommand { - command: cmd.to_string(), - }); - return; - } - - for image in &local_images { - items.push(UserInput::LocalImage { - path: image.path.clone(), - }); - } - - if !text.is_empty() { - items.push(UserInput::Text { - text: text.clone(), - text_elements: text_elements.clone(), - }); - } - - if let Some(skills) = self.bottom_pane.skills() { - let skill_mentions = find_skill_mentions(&text, skills); - for skill in skill_mentions { - items.push(UserInput::Skill { - name: skill.name.clone(), - path: skill.path.clone(), - }); - } - } - - let op = Op::UserTurn { - items, - cwd: self.config.cwd.clone(), - approval_policy: self.config.approval_policy.value(), - sandbox_policy: self.config.sandbox_policy.get().clone(), - model: self.stored_collaboration_mode.model().to_string(), - effort: self.stored_collaboration_mode.reasoning_effort(), - summary: self.config.model_reasoning_summary, - final_output_json_schema: None, - collaboration_mode: self - .collaboration_modes_enabled() - .then(|| self.stored_collaboration_mode.clone()), - }; - - if !self.agent_turn_running { - self.agent_turn_running = true; - self.update_task_running_state(); - } - - self.codex_op_tx.send(op).unwrap_or_else(|e| { - tracing::error!("failed to send message: {e}"); - }); - - // Persist the text to cross-session message history. - if !text.is_empty() { - self.codex_op_tx - .send(Op::AddToHistory { text: text.clone() }) - .unwrap_or_else(|e| { - tracing::error!("failed to send AddHistory op: {e}"); - }); - } - - // Only show the text portion in conversation history. - if !text.is_empty() { - let local_image_paths = local_images.into_iter().map(|img| img.path).collect(); - self.add_to_history(history_cell::new_user_prompt( - text, - text_elements, - local_image_paths, - )); - } - self.needs_final_message_separator = false; - } - - /// Replay a subset of initial events into the UI to seed the transcript when - /// resuming an existing session. This approximates the live event flow and - /// is intentionally conservative: only safe-to-replay items are rendered to - /// avoid triggering side effects. Event ids are passed as `None` to - /// distinguish replayed events from live ones. - fn replay_initial_messages(&mut self, events: Vec) { - for msg in events { - if matches!(msg, EventMsg::SessionConfigured(_)) { - continue; - } - // `id: None` indicates a synthetic/fake id coming from replay. - self.dispatch_event_msg(None, msg, true); - } - } - - pub(crate) fn handle_codex_event(&mut self, event: Event) { - let Event { id, msg } = event; - self.dispatch_event_msg(Some(id), msg, false); - } - - /// Dispatch a protocol `EventMsg` to the appropriate handler. - /// - /// `id` is `Some` for live events and `None` for replayed events from - /// `replay_initial_messages()`. Callers should treat `None` as a "fake" id - /// that must not be used to correlate follow-up actions. - fn dispatch_event_msg(&mut self, id: Option, msg: EventMsg, from_replay: bool) { - let is_stream_error = matches!(&msg, EventMsg::StreamError(_)); - if !is_stream_error { - self.restore_retry_status_header_if_present(); - } - - match msg { - EventMsg::AgentMessageDelta(_) - | EventMsg::AgentReasoningDelta(_) - | EventMsg::TerminalInteraction(_) - | EventMsg::ExecCommandOutputDelta(_) => {} - _ => { - tracing::trace!("handle_codex_event: {:?}", msg); - } - } - - match msg { - EventMsg::SessionConfigured(e) => self.on_session_configured(e), - EventMsg::AgentMessage(AgentMessageEvent { message }) => self.on_agent_message(message), - EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => { - self.on_agent_message_delta(delta) - } - EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta }) - | EventMsg::AgentReasoningRawContentDelta(AgentReasoningRawContentDeltaEvent { - delta, - }) => self.on_agent_reasoning_delta(delta), - EventMsg::AgentReasoning(AgentReasoningEvent { .. }) => self.on_agent_reasoning_final(), - EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { text }) => { - self.on_agent_reasoning_delta(text); - self.on_agent_reasoning_final(); - } - EventMsg::AgentReasoningSectionBreak(_) => self.on_reasoning_section_break(), - EventMsg::TurnStarted(_) => self.on_task_started(), - EventMsg::TurnComplete(TurnCompleteEvent { last_agent_message }) => { - self.on_task_complete(last_agent_message) - } - EventMsg::TokenCount(ev) => { - self.set_token_info(ev.info); - self.on_rate_limit_snapshot(ev.rate_limits); - } - EventMsg::Warning(WarningEvent { message }) => self.on_warning(message), - EventMsg::Error(ErrorEvent { message, .. }) => self.on_error(message), - EventMsg::McpStartupUpdate(ev) => self.on_mcp_startup_update(ev), - EventMsg::McpStartupComplete(ev) => self.on_mcp_startup_complete(ev), - EventMsg::TurnAborted(ev) => match ev.reason { - TurnAbortReason::Interrupted => { - self.on_interrupted_turn(ev.reason); - } - TurnAbortReason::Replaced => { - self.on_error("Turn aborted: replaced by a new task".to_owned()) - } - TurnAbortReason::ReviewEnded => { - self.on_interrupted_turn(ev.reason); - } - }, - EventMsg::PlanUpdate(update) => self.on_plan_update(update), - EventMsg::ExecApprovalRequest(ev) => { - // For replayed events, synthesize an empty id (these should not occur). - self.on_exec_approval_request(id.unwrap_or_default(), ev) - } - EventMsg::ApplyPatchApprovalRequest(ev) => { - self.on_apply_patch_approval_request(id.unwrap_or_default(), ev) - } - EventMsg::ElicitationRequest(ev) => { - self.on_elicitation_request(ev); - } - EventMsg::ExecCommandBegin(ev) => self.on_exec_command_begin(ev), - EventMsg::TerminalInteraction(delta) => self.on_terminal_interaction(delta), - EventMsg::ExecCommandOutputDelta(delta) => self.on_exec_command_output_delta(delta), - EventMsg::PatchApplyBegin(ev) => self.on_patch_apply_begin(ev), - EventMsg::PatchApplyEnd(ev) => self.on_patch_apply_end(ev), - EventMsg::ExecCommandEnd(ev) => self.on_exec_command_end(ev), - EventMsg::ViewImageToolCall(ev) => self.on_view_image_tool_call(ev), - EventMsg::McpToolCallBegin(ev) => self.on_mcp_tool_call_begin(ev), - EventMsg::McpToolCallEnd(ev) => self.on_mcp_tool_call_end(ev), - EventMsg::WebSearchBegin(ev) => self.on_web_search_begin(ev), - EventMsg::WebSearchEnd(ev) => self.on_web_search_end(ev), - EventMsg::GetHistoryEntryResponse(ev) => self.on_get_history_entry_response(ev), - EventMsg::McpListToolsResponse(ev) => self.on_list_mcp_tools(ev), - EventMsg::ListCustomPromptsResponse(ev) => self.on_list_custom_prompts(ev), - EventMsg::ListSkillsResponse(ev) => self.on_list_skills(ev), - EventMsg::SkillsUpdateAvailable => { - self.submit_op(Op::ListSkills { - cwds: Vec::new(), - force_reload: true, - }); - } - EventMsg::ShutdownComplete => self.on_shutdown_complete(), - EventMsg::TurnDiff(TurnDiffEvent { unified_diff }) => self.on_turn_diff(unified_diff), - EventMsg::DeprecationNotice(ev) => self.on_deprecation_notice(ev), - EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => { - self.on_background_event(message) - } - EventMsg::UndoStarted(ev) => self.on_undo_started(ev), - EventMsg::UndoCompleted(ev) => self.on_undo_completed(ev), - EventMsg::StreamError(StreamErrorEvent { - message, - additional_details, - .. - }) => self.on_stream_error(message, additional_details), - EventMsg::UserMessage(ev) => { - if from_replay { - self.on_user_message_event(ev); - } - } - EventMsg::EnteredReviewMode(review_request) => { - self.on_entered_review_mode(review_request) - } - EventMsg::ExitedReviewMode(review) => self.on_exited_review_mode(review), - EventMsg::ContextCompacted(_) => self.on_agent_message("Context compacted".to_owned()), - EventMsg::CollabAgentSpawnBegin(_) => {} - EventMsg::CollabAgentSpawnEnd(ev) => self.on_collab_event(collab::spawn_end(ev)), - EventMsg::CollabAgentInteractionBegin(_) => {} - EventMsg::CollabAgentInteractionEnd(ev) => { - self.on_collab_event(collab::interaction_end(ev)) - } - EventMsg::CollabWaitingBegin(ev) => self.on_collab_event(collab::waiting_begin(ev)), - EventMsg::CollabWaitingEnd(ev) => self.on_collab_event(collab::waiting_end(ev)), - EventMsg::CollabCloseBegin(_) => {} - EventMsg::CollabCloseEnd(ev) => self.on_collab_event(collab::close_end(ev)), - EventMsg::RawResponseItem(_) - | EventMsg::ThreadRolledBack(_) - | EventMsg::ItemStarted(_) - | EventMsg::ItemCompleted(_) - | EventMsg::AgentMessageContentDelta(_) - | EventMsg::ReasoningContentDelta(_) - | EventMsg::ReasoningRawContentDelta(_) - | EventMsg::RequestUserInput(_) => {} - } - } - - fn on_entered_review_mode(&mut self, review: ReviewRequest) { - // Enter review mode and emit a concise banner - if self.pre_review_token_info.is_none() { - self.pre_review_token_info = Some(self.token_info.clone()); - } - self.is_review_mode = true; - let hint = review - .user_facing_hint - .unwrap_or_else(|| codex_core::review_prompts::user_facing_hint(&review.target)); - let banner = format!(">> Code review started: {hint} <<"); - self.add_to_history(history_cell::new_review_status_line(banner)); - self.request_redraw(); - } - - fn on_exited_review_mode(&mut self, review: ExitedReviewModeEvent) { - // Leave review mode; if output is present, flush pending stream + show results. - if let Some(output) = review.review_output { - self.flush_answer_stream_with_separator(); - self.flush_interrupt_queue(); - self.flush_active_cell(); - - if output.findings.is_empty() { - let explanation = output.overall_explanation.trim().to_string(); - if explanation.is_empty() { - tracing::error!("Reviewer failed to output a response."); - self.add_to_history(history_cell::new_error_event( - "Reviewer failed to output a response.".to_owned(), - )); - } else { - // Show explanation when there are no structured findings. - let mut rendered: Vec> = vec!["".into()]; - append_markdown(&explanation, None, &mut rendered); - let body_cell = AgentMessageCell::new(rendered, false); - self.app_event_tx - .send(AppEvent::InsertHistoryCell(Box::new(body_cell))); - } - } - // Final message is rendered as part of the AgentMessage. - } - - self.is_review_mode = false; - self.restore_pre_review_token_info(); - // Append a finishing banner at the end of this turn. - self.add_to_history(history_cell::new_review_status_line( - "<< Code review finished >>".to_string(), - )); - self.request_redraw(); - } - - fn on_user_message_event(&mut self, event: UserMessageEvent) { - if !event.message.trim().is_empty() { - self.add_to_history(history_cell::new_user_prompt( - event.message, - event.text_elements, - event.local_images, - )); - } - - self.needs_final_message_separator = false; - } - - /// Exit the UI immediately without waiting for shutdown. - /// - /// Prefer [`Self::request_quit_without_confirmation`] for user-initiated exits; - /// this is mainly a fallback for shutdown completion or emergency exits. - fn request_immediate_exit(&self) { - self.app_event_tx.send(AppEvent::Exit(ExitMode::Immediate)); - } - - /// Request a shutdown-first quit. - /// - /// This is used for explicit quit commands (`/quit`, `/exit`, `/logout`) and for - /// the double-press Ctrl+C/Ctrl+D quit shortcut. - fn request_quit_without_confirmation(&self) { - self.app_event_tx - .send(AppEvent::Exit(ExitMode::ShutdownFirst)); - } - - fn request_redraw(&mut self) { - self.frame_requester.schedule_frame(); - } - - fn bump_active_cell_revision(&mut self) { - // Wrapping avoids overflow; wraparound would require 2^64 bumps and at - // worst causes a one-time cache-key collision. - self.active_cell_revision = self.active_cell_revision.wrapping_add(1); - } - - fn notify(&mut self, notification: Notification) { - if !notification.allowed_for(&self.config.tui_notifications) { - return; - } - self.pending_notification = Some(notification); - self.request_redraw(); - } - - pub(crate) fn maybe_post_pending_notification(&mut self, tui: &mut crate::tui::Tui) { - if let Some(notif) = self.pending_notification.take() { - tui.notify(notif.display()); - } - } - - /// Mark the active cell as failed (✗) and flush it into history. - fn finalize_active_cell_as_failed(&mut self) { - if let Some(mut cell) = self.active_cell.take() { - // Insert finalized cell into history and keep grouping consistent. - if let Some(exec) = cell.as_any_mut().downcast_mut::() { - exec.mark_failed(); - } else if let Some(tool) = cell.as_any_mut().downcast_mut::() { - tool.mark_failed(); - } - self.add_boxed_history(cell); - } - } - - // If idle and there are queued inputs, submit exactly one to start the next turn. - fn maybe_send_next_queued_input(&mut self) { - if self.bottom_pane.is_task_running() { - return; - } - if let Some(user_message) = self.queued_user_messages.pop_front() { - self.submit_user_message(user_message); - } - // Update the list to reflect the remaining queued messages (if any). - self.refresh_queued_user_messages(); - } - - /// Rebuild and update the queued user messages from the current queue. - fn refresh_queued_user_messages(&mut self) { - let messages: Vec = self - .queued_user_messages - .iter() - .map(|m| m.text.clone()) - .collect(); - self.bottom_pane.set_queued_user_messages(messages); - } - - pub(crate) fn add_diff_in_progress(&mut self) { - self.request_redraw(); - } - - pub(crate) fn on_diff_complete(&mut self) { - self.request_redraw(); - } - - pub(crate) fn add_status_output(&mut self) { - let default_usage = TokenUsage::default(); - let token_info = self.token_info.as_ref(); - let total_usage = token_info - .map(|ti| &ti.total_token_usage) - .unwrap_or(&default_usage); - let reasoning_effort_override = Some(self.stored_collaboration_mode.reasoning_effort()); - self.add_to_history(crate::status::new_status_output( - &self.config, - self.auth_manager.as_ref(), - token_info, - total_usage, - &self.conversation_id, - self.forked_from, - self.rate_limit_snapshot.as_ref(), - self.plan_type, - Local::now(), - self.model_display_name(), - self.collaboration_mode_label(), - reasoning_effort_override, - )); - } - fn stop_rate_limit_poller(&mut self) { - if let Some(handle) = self.rate_limit_poller.take() { - handle.abort(); - } - } - - fn prefetch_rate_limits(&mut self) { - self.stop_rate_limit_poller(); - - if self.auth_manager.auth_cached().map(|auth| auth.mode) != Some(AuthMode::ChatGPT) { - return; - } - - let base_url = self.config.chatgpt_base_url.clone(); - let app_event_tx = self.app_event_tx.clone(); - let auth_manager = Arc::clone(&self.auth_manager); - - let handle = tokio::spawn(async move { - let mut interval = tokio::time::interval(Duration::from_secs(60)); - - loop { - if let Some(auth) = auth_manager.auth().await - && auth.mode == AuthMode::ChatGPT - && let Some(snapshot) = fetch_rate_limits(base_url.clone(), auth).await - { - app_event_tx.send(AppEvent::RateLimitSnapshotFetched(snapshot)); - } - interval.tick().await; - } - }); - - self.rate_limit_poller = Some(handle); - } - - fn lower_cost_preset(&self) -> Option { - let models = self.models_manager.try_list_models(&self.config).ok()?; - models - .iter() - .find(|preset| preset.show_in_picker && preset.model == NUDGE_MODEL_SLUG) - .cloned() - } - - fn rate_limit_switch_prompt_hidden(&self) -> bool { - self.config - .notices - .hide_rate_limit_model_nudge - .unwrap_or(false) - } - - fn maybe_show_pending_rate_limit_prompt(&mut self) { - if self.rate_limit_switch_prompt_hidden() { - self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle; - return; - } - if !matches!( - self.rate_limit_switch_prompt, - RateLimitSwitchPromptState::Pending - ) { - return; - } - if let Some(preset) = self.lower_cost_preset() { - self.open_rate_limit_switch_prompt(preset); - self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Shown; - } else { - self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle; - } - } - - fn open_rate_limit_switch_prompt(&mut self, preset: ModelPreset) { - let switch_model = preset.model.to_string(); - let display_name = preset.display_name.to_string(); - let default_effort: ReasoningEffortConfig = preset.default_reasoning_effort; - - let switch_actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::CodexOp(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - sandbox_policy: None, - model: Some(switch_model.clone()), - effort: Some(Some(default_effort)), - summary: None, - collaboration_mode: None, - })); - tx.send(AppEvent::UpdateModel(switch_model.clone())); - tx.send(AppEvent::UpdateReasoningEffort(Some(default_effort))); - })]; - - let keep_actions: Vec = Vec::new(); - let never_actions: Vec = vec![Box::new(|tx| { - tx.send(AppEvent::UpdateRateLimitSwitchPromptHidden(true)); - tx.send(AppEvent::PersistRateLimitSwitchPromptHidden); - })]; - let description = if preset.description.is_empty() { - Some("Uses fewer credits for upcoming turns.".to_string()) - } else { - Some(preset.description) - }; - - let items = vec![ - SelectionItem { - name: format!("Switch to {display_name}"), - description, - selected_description: None, - is_current: false, - actions: switch_actions, - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "Keep current model".to_string(), - description: None, - selected_description: None, - is_current: false, - actions: keep_actions, - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "Keep current model (never show again)".to_string(), - description: Some( - "Hide future rate limit reminders about switching models.".to_string(), - ), - selected_description: None, - is_current: false, - actions: never_actions, - dismiss_on_select: true, - ..Default::default() - }, - ]; - - self.bottom_pane.show_selection_view(SelectionViewParams { - title: Some("Approaching rate limits".to_string()), - subtitle: Some(format!("Switch to {display_name} for lower credit usage?")), - footer_hint: Some(standard_popup_hint_line()), - items, - ..Default::default() - }); - } - - /// Open a popup to choose a quick auto model. Selecting "All models" - /// opens the full picker with every available preset. - pub(crate) fn open_model_popup(&mut self) { - if !self.is_session_configured() { - self.add_info_message( - "Model selection is disabled until startup completes.".to_string(), - None, - ); - return; - } - - let presets: Vec = match self.models_manager.try_list_models(&self.config) { - Ok(models) => models, - Err(_) => { - self.add_info_message( - "Models are being updated; please try /model again in a moment.".to_string(), - None, - ); - return; - } - }; - self.open_model_popup_with_presets(presets); - } - - pub(crate) fn open_model_popup_with_presets(&mut self, presets: Vec) { - let presets: Vec = presets - .into_iter() - .filter(|preset| preset.show_in_picker) - .collect(); - - let current_model = self.current_model(); - let current_label = presets - .iter() - .find(|preset| preset.model.as_str() == current_model) - .map(|preset| preset.display_name.to_string()) - .unwrap_or_else(|| self.model_display_name().to_string()); - - let (mut auto_presets, other_presets): (Vec, Vec) = presets - .into_iter() - .partition(|preset| Self::is_auto_model(&preset.model)); - - if auto_presets.is_empty() { - self.open_all_models_popup(other_presets); - return; - } - - auto_presets.sort_by_key(|preset| Self::auto_model_order(&preset.model)); - - let mut items: Vec = auto_presets - .into_iter() - .map(|preset| { - let description = - (!preset.description.is_empty()).then_some(preset.description.clone()); - let model = preset.model.clone(); - let actions = Self::model_selection_actions( - model.clone(), - Some(preset.default_reasoning_effort), - ); - SelectionItem { - name: preset.display_name.clone(), - description, - is_current: model.as_str() == current_model, - is_default: preset.is_default, - actions, - dismiss_on_select: true, - ..Default::default() - } - }) - .collect(); - - if !other_presets.is_empty() { - let all_models = other_presets; - let actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::OpenAllModelsPopup { - models: all_models.clone(), - }); - })]; - - let is_current = !items.iter().any(|item| item.is_current); - let description = Some(format!( - "Choose a specific model and reasoning level (current: {current_label})" - )); - - items.push(SelectionItem { - name: "All models".to_string(), - description, - is_current, - actions, - dismiss_on_select: true, - ..Default::default() - }); - } - - self.bottom_pane.show_selection_view(SelectionViewParams { - title: Some("Select Model".to_string()), - subtitle: Some("Pick a quick auto mode or browse all models.".to_string()), - footer_hint: Some(standard_popup_hint_line()), - items, - ..Default::default() - }); - } - - fn is_auto_model(model: &str) -> bool { - model.starts_with("codex-auto-") - } - - fn auto_model_order(model: &str) -> usize { - match model { - "codex-auto-fast" => 0, - "codex-auto-balanced" => 1, - "codex-auto-thorough" => 2, - _ => 3, - } - } - - pub(crate) fn open_all_models_popup(&mut self, presets: Vec) { - if presets.is_empty() { - self.add_info_message( - "No additional models are available right now.".to_string(), - None, - ); - return; - } - - let mut items: Vec = Vec::new(); - for preset in presets.into_iter() { - let description = - (!preset.description.is_empty()).then_some(preset.description.to_string()); - let is_current = preset.model.as_str() == self.current_model(); - let single_supported_effort = preset.supported_reasoning_efforts.len() == 1; - let preset_for_action = preset.clone(); - let actions: Vec = vec![Box::new(move |tx| { - let preset_for_event = preset_for_action.clone(); - tx.send(AppEvent::OpenReasoningPopup { - model: preset_for_event, - }); - })]; - items.push(SelectionItem { - name: preset.display_name.clone(), - description, - is_current, - is_default: preset.is_default, - actions, - dismiss_on_select: single_supported_effort, - ..Default::default() - }); - } - - self.bottom_pane.show_selection_view(SelectionViewParams { - title: Some("Select Model and Effort".to_string()), - subtitle: Some( - "Access legacy models by running codex -m or in your config.toml" - .to_string(), - ), - footer_hint: Some("Press enter to select reasoning effort, or esc to dismiss.".into()), - items, - ..Default::default() - }); - } - - pub(crate) fn open_collaboration_modes_popup(&mut self) { - let presets = self.models_manager.list_collaboration_modes(); - if presets.is_empty() { - self.add_info_message( - "No collaboration modes are available right now.".to_string(), - None, - ); - return; - } - - let items: Vec = presets - .into_iter() - .map(|preset| { - let name = match preset { - CollaborationMode::Plan(_) => "Plan", - CollaborationMode::PairProgramming(_) => "Pair Programming", - CollaborationMode::Execute(_) => "Execute", - CollaborationMode::Custom(_) => "Custom", - }; - let is_current = - collaboration_modes::same_variant(&self.stored_collaboration_mode, &preset); - let actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::UpdateCollaborationMode(preset.clone())); - })]; - SelectionItem { - name: name.to_string(), - is_current, - actions, - dismiss_on_select: true, - ..Default::default() - } - }) - .collect(); - - self.bottom_pane.show_selection_view(SelectionViewParams { - title: Some("Select Collaboration Mode".to_string()), - subtitle: Some("Pick a collaboration preset.".to_string()), - footer_hint: Some(standard_popup_hint_line()), - items, - ..Default::default() - }); - } - - fn model_selection_actions( - model_for_action: String, - effort_for_action: Option, - ) -> Vec { - vec![Box::new(move |tx| { - let effort_label = effort_for_action - .map(|effort| effort.to_string()) - .unwrap_or_else(|| "default".to_string()); - tx.send(AppEvent::CodexOp(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - sandbox_policy: None, - model: Some(model_for_action.clone()), - effort: Some(effort_for_action), - summary: None, - collaboration_mode: None, - })); - tx.send(AppEvent::UpdateModel(model_for_action.clone())); - tx.send(AppEvent::UpdateReasoningEffort(effort_for_action)); - tx.send(AppEvent::PersistModelSelection { - model: model_for_action.clone(), - effort: effort_for_action, - }); - tracing::info!( - "Selected model: {}, Selected effort: {}", - model_for_action, - effort_label - ); - })] - } - - /// Open a popup to choose the reasoning effort (stage 2) for the given model. - pub(crate) fn open_reasoning_popup(&mut self, preset: ModelPreset) { - let default_effort: ReasoningEffortConfig = preset.default_reasoning_effort; - let supported = preset.supported_reasoning_efforts; - - let warn_effort = if supported - .iter() - .any(|option| option.effort == ReasoningEffortConfig::XHigh) - { - Some(ReasoningEffortConfig::XHigh) - } else if supported - .iter() - .any(|option| option.effort == ReasoningEffortConfig::High) - { - Some(ReasoningEffortConfig::High) - } else { - None - }; - let warning_text = warn_effort.map(|effort| { - let effort_label = Self::reasoning_effort_label(effort); - format!("⚠ {effort_label} reasoning effort can quickly consume Plus plan rate limits.") - }); - let warn_for_model = preset.model.starts_with("gpt-5.1-codex") - || preset.model.starts_with("gpt-5.1-codex-max") - || preset.model.starts_with("gpt-5.2"); - - struct EffortChoice { - stored: Option, - display: ReasoningEffortConfig, - } - let mut choices: Vec = Vec::new(); - for effort in ReasoningEffortConfig::iter() { - if supported.iter().any(|option| option.effort == effort) { - choices.push(EffortChoice { - stored: Some(effort), - display: effort, - }); - } - } - if choices.is_empty() { - choices.push(EffortChoice { - stored: Some(default_effort), - display: default_effort, - }); - } - - if choices.len() == 1 { - if let Some(effort) = choices.first().and_then(|c| c.stored) { - self.apply_model_and_effort(preset.model, Some(effort)); - } else { - self.apply_model_and_effort(preset.model, None); - } - return; - } - - let default_choice: Option = choices - .iter() - .any(|choice| choice.stored == Some(default_effort)) - .then_some(Some(default_effort)) - .flatten() - .or_else(|| choices.iter().find_map(|choice| choice.stored)) - .or(Some(default_effort)); - - let model_slug = preset.model.to_string(); - let is_current_model = self.current_model() == preset.model.as_str(); - let highlight_choice = if is_current_model { - self.stored_collaboration_mode.reasoning_effort() - } else { - default_choice - }; - let selection_choice = highlight_choice.or(default_choice); - let initial_selected_idx = choices - .iter() - .position(|choice| choice.stored == selection_choice) - .or_else(|| { - selection_choice - .and_then(|effort| choices.iter().position(|choice| choice.display == effort)) - }); - let mut items: Vec = Vec::new(); - for choice in choices.iter() { - let effort = choice.display; - let mut effort_label = Self::reasoning_effort_label(effort).to_string(); - if choice.stored == default_choice { - effort_label.push_str(" (default)"); - } - - let description = choice - .stored - .and_then(|effort| { - supported - .iter() - .find(|option| option.effort == effort) - .map(|option| option.description.to_string()) - }) - .filter(|text| !text.is_empty()); - - let show_warning = warn_for_model && warn_effort == Some(effort); - let selected_description = if show_warning { - warning_text.as_ref().map(|warning_message| { - description.as_ref().map_or_else( - || warning_message.clone(), - |d| format!("{d}\n{warning_message}"), - ) - }) - } else { - None - }; - - let model_for_action = model_slug.clone(); - let actions = Self::model_selection_actions(model_for_action, choice.stored); - - items.push(SelectionItem { - name: effort_label, - description, - selected_description, - is_current: is_current_model && choice.stored == highlight_choice, - actions, - dismiss_on_select: true, - ..Default::default() - }); - } - - let mut header = ColumnRenderable::new(); - header.push(Line::from( - format!("Select Reasoning Level for {model_slug}").bold(), - )); - - self.bottom_pane.show_selection_view(SelectionViewParams { - header: Box::new(header), - footer_hint: Some(standard_popup_hint_line()), - items, - initial_selected_idx, - ..Default::default() - }); - } - - fn reasoning_effort_label(effort: ReasoningEffortConfig) -> &'static str { - match effort { - ReasoningEffortConfig::None => "None", - ReasoningEffortConfig::Minimal => "Minimal", - ReasoningEffortConfig::Low => "Low", - ReasoningEffortConfig::Medium => "Medium", - ReasoningEffortConfig::High => "High", - ReasoningEffortConfig::XHigh => "Extra high", - } - } - - fn apply_model_and_effort(&self, model: String, effort: Option) { - self.app_event_tx - .send(AppEvent::CodexOp(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - sandbox_policy: None, - model: Some(model.clone()), - effort: Some(effort), - summary: None, - collaboration_mode: None, - })); - self.app_event_tx.send(AppEvent::UpdateModel(model.clone())); - self.app_event_tx - .send(AppEvent::UpdateReasoningEffort(effort)); - self.app_event_tx.send(AppEvent::PersistModelSelection { - model: model.clone(), - effort, - }); - tracing::info!( - "Selected model: {}, Selected effort: {}", - model, - effort - .map(|e| e.to_string()) - .unwrap_or_else(|| "default".to_string()) - ); - } - - /// Open a popup to choose the approvals mode (ask for approval policy + sandbox policy). - pub(crate) fn open_approvals_popup(&mut self) { - let current_approval = self.config.approval_policy.value(); - let current_sandbox = self.config.sandbox_policy.get(); - let mut items: Vec = Vec::new(); - let presets: Vec = builtin_approval_presets(); - - #[cfg(target_os = "windows")] - let windows_degraded_sandbox_enabled = codex_core::get_platform_sandbox().is_some() - && !codex_core::is_windows_elevated_sandbox_enabled(); - #[cfg(not(target_os = "windows"))] - let windows_degraded_sandbox_enabled = false; - - let show_elevate_sandbox_hint = codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED - && windows_degraded_sandbox_enabled - && presets.iter().any(|preset| preset.id == "auto"); - - for preset in presets.into_iter() { - let is_current = - Self::preset_matches_current(current_approval, current_sandbox, &preset); - let name = if preset.id == "auto" && windows_degraded_sandbox_enabled { - "Agent (non-elevated sandbox)".to_string() - } else { - preset.label.to_string() - }; - let description_text = preset.description; - let description = Some(description_text.to_string()); - let requires_confirmation = preset.id == "full-access" - && !self - .config - .notices - .hide_full_access_warning - .unwrap_or(false); - let actions: Vec = if requires_confirmation { - let preset_clone = preset.clone(); - vec![Box::new(move |tx| { - tx.send(AppEvent::OpenFullAccessConfirmation { - preset: preset_clone.clone(), - }); - })] - } else if preset.id == "auto" { - #[cfg(target_os = "windows")] - { - if codex_core::get_platform_sandbox().is_none() { - let preset_clone = preset.clone(); - if codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED - && codex_core::windows_sandbox::sandbox_setup_is_complete( - self.config.codex_home.as_path(), - ) - { - vec![Box::new(move |tx| { - tx.send(AppEvent::EnableWindowsSandboxForAgentMode { - preset: preset_clone.clone(), - mode: WindowsSandboxEnableMode::Elevated, - }); - })] - } else { - vec![Box::new(move |tx| { - tx.send(AppEvent::OpenWindowsSandboxEnablePrompt { - preset: preset_clone.clone(), - }); - })] - } - } else if let Some((sample_paths, extra_count, failed_scan)) = - self.world_writable_warning_details() - { - let preset_clone = preset.clone(); - vec![Box::new(move |tx| { - tx.send(AppEvent::OpenWorldWritableWarningConfirmation { - preset: Some(preset_clone.clone()), - sample_paths: sample_paths.clone(), - extra_count, - failed_scan, - }); - })] - } else { - Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) - } - } - #[cfg(not(target_os = "windows"))] - { - Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) - } - } else { - Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) - }; - items.push(SelectionItem { - name, - description, - is_current, - actions, - dismiss_on_select: true, - ..Default::default() - }); - } - - let footer_note = show_elevate_sandbox_hint.then(|| { - vec![ - "The non-elevated sandbox protects your files and prevents network access under most circumstances. However, it carries greater risk if prompt injected. To upgrade to the elevated sandbox, run ".dim(), - "/setup-elevated-sandbox".cyan(), - ".".dim(), - ] - .into() - }); - - self.bottom_pane.show_selection_view(SelectionViewParams { - title: Some("Select Approval Mode".to_string()), - footer_note, - footer_hint: Some(standard_popup_hint_line()), - items, - header: Box::new(()), - ..Default::default() - }); - } - - pub(crate) fn open_experimental_popup(&mut self) { - let features: Vec = FEATURES - .iter() - .filter_map(|spec| { - let name = spec.stage.beta_menu_name()?; - let description = spec.stage.beta_menu_description()?; - Some(BetaFeatureItem { - feature: spec.id, - name: name.to_string(), - description: description.to_string(), - enabled: self.config.features.enabled(spec.id), - }) - }) - .collect(); - - let view = ExperimentalFeaturesView::new(features, self.app_event_tx.clone()); - self.bottom_pane.show_view(Box::new(view)); - } - - fn approval_preset_actions( - approval: AskForApproval, - sandbox: SandboxPolicy, - ) -> Vec { - vec![Box::new(move |tx| { - let sandbox_clone = sandbox.clone(); - tx.send(AppEvent::CodexOp(Op::OverrideTurnContext { - cwd: None, - approval_policy: Some(approval), - sandbox_policy: Some(sandbox_clone.clone()), - model: None, - effort: None, - summary: None, - collaboration_mode: None, - })); - tx.send(AppEvent::UpdateAskForApprovalPolicy(approval)); - tx.send(AppEvent::UpdateSandboxPolicy(sandbox_clone)); - })] - } - - fn preset_matches_current( - current_approval: AskForApproval, - current_sandbox: &SandboxPolicy, - preset: &ApprovalPreset, - ) -> bool { - if current_approval != preset.approval { - return false; - } - matches!( - (&preset.sandbox, current_sandbox), - (SandboxPolicy::ReadOnly, SandboxPolicy::ReadOnly) - | ( - SandboxPolicy::DangerFullAccess, - SandboxPolicy::DangerFullAccess - ) - | ( - SandboxPolicy::WorkspaceWrite { .. }, - SandboxPolicy::WorkspaceWrite { .. } - ) - ) - } - - #[cfg(target_os = "windows")] - pub(crate) fn world_writable_warning_details(&self) -> Option<(Vec, usize, bool)> { - if self - .config - .notices - .hide_world_writable_warning - .unwrap_or(false) - { - return None; - } - let cwd = self.config.cwd.clone(); - let env_map: std::collections::HashMap = std::env::vars().collect(); - match codex_windows_sandbox::apply_world_writable_scan_and_denies( - self.config.codex_home.as_path(), - cwd.as_path(), - &env_map, - self.config.sandbox_policy.get(), - Some(self.config.codex_home.as_path()), - ) { - Ok(_) => None, - Err(_) => Some((Vec::new(), 0, true)), - } - } - - #[cfg(not(target_os = "windows"))] - #[allow(dead_code)] - pub(crate) fn world_writable_warning_details(&self) -> Option<(Vec, usize, bool)> { - None - } - - pub(crate) fn open_full_access_confirmation(&mut self, preset: ApprovalPreset) { - let approval = preset.approval; - let sandbox = preset.sandbox; - let mut header_children: Vec> = Vec::new(); - let title_line = Line::from("Enable full access?").bold(); - let info_line = Line::from(vec![ - "When Codex runs with full access, it can edit any file on your computer and run commands with network, without your approval. " - .into(), - "Exercise caution when enabling full access. This significantly increases the risk of data loss, leaks, or unexpected behavior." - .fg(Color::Red), - ]); - header_children.push(Box::new(title_line)); - header_children.push(Box::new( - Paragraph::new(vec![info_line]).wrap(Wrap { trim: false }), - )); - let header = ColumnRenderable::with(header_children); - - let mut accept_actions = Self::approval_preset_actions(approval, sandbox.clone()); - accept_actions.push(Box::new(|tx| { - tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true)); - })); - - let mut accept_and_remember_actions = Self::approval_preset_actions(approval, sandbox); - accept_and_remember_actions.push(Box::new(|tx| { - tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true)); - tx.send(AppEvent::PersistFullAccessWarningAcknowledged); - })); - - let deny_actions: Vec = vec![Box::new(|tx| { - tx.send(AppEvent::OpenApprovalsPopup); - })]; - - let items = vec![ - SelectionItem { - name: "Yes, continue anyway".to_string(), - description: Some("Apply full access for this session".to_string()), - actions: accept_actions, - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "Yes, and don't ask again".to_string(), - description: Some("Enable full access and remember this choice".to_string()), - actions: accept_and_remember_actions, - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "Cancel".to_string(), - description: Some("Go back without enabling full access".to_string()), - actions: deny_actions, - dismiss_on_select: true, - ..Default::default() - }, - ]; - - self.bottom_pane.show_selection_view(SelectionViewParams { - footer_hint: Some(standard_popup_hint_line()), - items, - header: Box::new(header), - ..Default::default() - }); - } - - #[cfg(target_os = "windows")] - pub(crate) fn open_world_writable_warning_confirmation( - &mut self, - preset: Option, - sample_paths: Vec, - extra_count: usize, - failed_scan: bool, - ) { - let (approval, sandbox) = match &preset { - Some(p) => (Some(p.approval), Some(p.sandbox.clone())), - None => (None, None), - }; - let mut header_children: Vec> = Vec::new(); - let describe_policy = |policy: &SandboxPolicy| match policy { - SandboxPolicy::WorkspaceWrite { .. } => "Agent mode", - SandboxPolicy::ReadOnly => "Read-Only mode", - _ => "Agent mode", - }; - let mode_label = preset - .as_ref() - .map(|p| describe_policy(&p.sandbox)) - .unwrap_or_else(|| describe_policy(self.config.sandbox_policy.get())); - let info_line = if failed_scan { - Line::from(vec![ - "We couldn't complete the world-writable scan, so protections cannot be verified. " - .into(), - format!("The Windows sandbox cannot guarantee protection in {mode_label}.") - .fg(Color::Red), - ]) - } else { - Line::from(vec![ - "The Windows sandbox cannot protect writes to folders that are writable by Everyone.".into(), - " Consider removing write access for Everyone from the following folders:".into(), - ]) - }; - header_children.push(Box::new( - Paragraph::new(vec![info_line]).wrap(Wrap { trim: false }), - )); - - if !sample_paths.is_empty() { - // Show up to three examples and optionally an "and X more" line. - let mut lines: Vec = Vec::new(); - lines.push(Line::from("")); - for p in &sample_paths { - lines.push(Line::from(format!(" - {p}"))); - } - if extra_count > 0 { - lines.push(Line::from(format!("and {extra_count} more"))); - } - header_children.push(Box::new(Paragraph::new(lines).wrap(Wrap { trim: false }))); - } - let header = ColumnRenderable::with(header_children); - - // Build actions ensuring acknowledgement happens before applying the new sandbox policy, - // so downstream policy-change hooks don't re-trigger the warning. - let mut accept_actions: Vec = Vec::new(); - // Suppress the immediate re-scan only when a preset will be applied (i.e., via /approvals), - // to avoid duplicate warnings from the ensuing policy change. - if preset.is_some() { - accept_actions.push(Box::new(|tx| { - tx.send(AppEvent::SkipNextWorldWritableScan); - })); - } - if let (Some(approval), Some(sandbox)) = (approval, sandbox.clone()) { - accept_actions.extend(Self::approval_preset_actions(approval, sandbox)); - } - - let mut accept_and_remember_actions: Vec = Vec::new(); - accept_and_remember_actions.push(Box::new(|tx| { - tx.send(AppEvent::UpdateWorldWritableWarningAcknowledged(true)); - tx.send(AppEvent::PersistWorldWritableWarningAcknowledged); - })); - if let (Some(approval), Some(sandbox)) = (approval, sandbox) { - accept_and_remember_actions.extend(Self::approval_preset_actions(approval, sandbox)); - } - - let items = vec![ - SelectionItem { - name: "Continue".to_string(), - description: Some(format!("Apply {mode_label} for this session")), - actions: accept_actions, - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "Continue and don't warn again".to_string(), - description: Some(format!("Enable {mode_label} and remember this choice")), - actions: accept_and_remember_actions, - dismiss_on_select: true, - ..Default::default() - }, - ]; - - self.bottom_pane.show_selection_view(SelectionViewParams { - footer_hint: Some(standard_popup_hint_line()), - items, - header: Box::new(header), - ..Default::default() - }); - } - - #[cfg(not(target_os = "windows"))] - pub(crate) fn open_world_writable_warning_confirmation( - &mut self, - _preset: Option, - _sample_paths: Vec, - _extra_count: usize, - _failed_scan: bool, - ) { - } - - #[cfg(target_os = "windows")] - pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, preset: ApprovalPreset) { - use ratatui_macros::line; - - if !codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED { - // Legacy flow (pre-NUX): explain the experimental sandbox and let the user enable it - // directly (no elevation prompts). - let mut header = ColumnRenderable::new(); - header.push(*Box::new( - Paragraph::new(vec![ - line!["Agent mode on Windows uses an experimental sandbox to limit network and filesystem access.".bold()], - line!["Learn more: https://developers.openai.com/codex/windows"], - ]) - .wrap(Wrap { trim: false }), - )); - - let preset_clone = preset; - let items = vec![ - SelectionItem { - name: "Enable experimental sandbox".to_string(), - description: None, - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::EnableWindowsSandboxForAgentMode { - preset: preset_clone.clone(), - mode: WindowsSandboxEnableMode::Legacy, - }); - })], - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "Go back".to_string(), - description: None, - actions: vec![Box::new(|tx| { - tx.send(AppEvent::OpenApprovalsPopup); - })], - dismiss_on_select: true, - ..Default::default() - }, - ]; - - self.bottom_pane.show_selection_view(SelectionViewParams { - title: None, - footer_hint: Some(standard_popup_hint_line()), - items, - header: Box::new(header), - ..Default::default() - }); - return; - } - - let current_approval = self.config.approval_policy.value(); - let current_sandbox = self.config.sandbox_policy.get(); - let presets = builtin_approval_presets(); - let stay_full_access = presets - .iter() - .find(|preset| preset.id == "full-access") - .is_some_and(|preset| { - Self::preset_matches_current(current_approval, current_sandbox, preset) - }); - self.otel_manager - .counter("codex.windows_sandbox.elevated_prompt_shown", 1, &[]); - - let mut header = ColumnRenderable::new(); - header.push(*Box::new( - Paragraph::new(vec![ - line!["Set Up Agent Sandbox".bold()], - line![""], - line!["Agent mode uses an experimental Windows sandbox that protects your files and prevents network access by default."], - line!["Learn more: https://developers.openai.com/codex/windows"], - ]) - .wrap(Wrap { trim: false }), - )); - - let stay_label = if stay_full_access { - "Stay in Agent Full Access".to_string() - } else { - "Stay in Read-Only".to_string() - }; - let mut stay_actions = if stay_full_access { - Vec::new() - } else { - presets - .iter() - .find(|preset| preset.id == "read-only") - .map(|preset| { - Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) - }) - .unwrap_or_default() - }; - stay_actions.insert( - 0, - Box::new({ - let otel = self.otel_manager.clone(); - move |_tx| { - otel.counter("codex.windows_sandbox.elevated_prompt_decline", 1, &[]); - } - }), - ); - - let accept_otel = self.otel_manager.clone(); - let items = vec![ - SelectionItem { - name: "Set up agent sandbox (requires elevation)".to_string(), - description: None, - actions: vec![Box::new(move |tx| { - accept_otel.counter("codex.windows_sandbox.elevated_prompt_accept", 1, &[]); - tx.send(AppEvent::BeginWindowsSandboxElevatedSetup { - preset: preset.clone(), - }); - })], - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: stay_label, - description: None, - actions: stay_actions, - dismiss_on_select: true, - ..Default::default() - }, - ]; - - self.bottom_pane.show_selection_view(SelectionViewParams { - title: None, - footer_hint: Some(standard_popup_hint_line()), - items, - header: Box::new(header), - ..Default::default() - }); - } - - #[cfg(not(target_os = "windows"))] - pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, _preset: ApprovalPreset) {} - - #[cfg(target_os = "windows")] - pub(crate) fn open_windows_sandbox_fallback_prompt( - &mut self, - preset: ApprovalPreset, - reason: WindowsSandboxFallbackReason, - ) { - use ratatui_macros::line; - - let _ = reason; - - let current_approval = self.config.approval_policy.value(); - let current_sandbox = self.config.sandbox_policy.get(); - let presets = builtin_approval_presets(); - let stay_full_access = presets - .iter() - .find(|preset| preset.id == "full-access") - .is_some_and(|preset| { - Self::preset_matches_current(current_approval, current_sandbox, preset) - }); - let mut lines = Vec::new(); - lines.push(line!["Use Non-Elevated Sandbox?".bold()]); - lines.push(line![""]); - lines.push(line![ - "Elevation failed. You can also use a non-elevated sandbox, which protects your files and prevents network access under most circumstances. However, it carries greater risk if prompt injected." - ]); - lines.push(line![ - "Learn more: https://developers.openai.com/codex/windows" - ]); - - let mut header = ColumnRenderable::new(); - header.push(*Box::new(Paragraph::new(lines).wrap(Wrap { trim: false }))); - - let elevated_preset = preset.clone(); - let legacy_preset = preset; - let stay_label = if stay_full_access { - "Stay in Agent Full Access".to_string() - } else { - "Stay in Read-Only".to_string() - }; - let mut stay_actions = if stay_full_access { - Vec::new() - } else { - presets - .iter() - .find(|preset| preset.id == "read-only") - .map(|preset| { - Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) - }) - .unwrap_or_default() - }; - stay_actions.insert( - 0, - Box::new({ - let otel = self.otel_manager.clone(); - move |_tx| { - otel.counter("codex.windows_sandbox.fallback_stay_current", 1, &[]); - } - }), - ); - let items = vec![ - SelectionItem { - name: "Try elevated agent sandbox setup again".to_string(), - description: None, - actions: vec![Box::new({ - let otel = self.otel_manager.clone(); - let preset = elevated_preset; - move |tx| { - otel.counter("codex.windows_sandbox.fallback_retry_elevated", 1, &[]); - tx.send(AppEvent::BeginWindowsSandboxElevatedSetup { - preset: preset.clone(), - }); - } - })], - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "Use non-elevated agent sandbox".to_string(), - description: None, - actions: vec![Box::new({ - let otel = self.otel_manager.clone(); - let preset = legacy_preset; - move |tx| { - otel.counter("codex.windows_sandbox.fallback_use_legacy", 1, &[]); - tx.send(AppEvent::EnableWindowsSandboxForAgentMode { - preset: preset.clone(), - mode: WindowsSandboxEnableMode::Legacy, - }); - } - })], - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: stay_label, - description: None, - actions: stay_actions, - dismiss_on_select: true, - ..Default::default() - }, - ]; - - self.bottom_pane.show_selection_view(SelectionViewParams { - title: None, - footer_hint: Some(standard_popup_hint_line()), - items, - header: Box::new(header), - ..Default::default() - }); - } - - #[cfg(not(target_os = "windows"))] - pub(crate) fn open_windows_sandbox_fallback_prompt( - &mut self, - _preset: ApprovalPreset, - _reason: WindowsSandboxFallbackReason, - ) { - } - - #[cfg(target_os = "windows")] - pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self) { - if self.config.forced_auto_mode_downgraded_on_windows - && codex_core::get_platform_sandbox().is_none() - && let Some(preset) = builtin_approval_presets() - .into_iter() - .find(|preset| preset.id == "auto") - { - self.open_windows_sandbox_enable_prompt(preset); - } - } - - #[cfg(not(target_os = "windows"))] - pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self) {} - - #[cfg(target_os = "windows")] - pub(crate) fn show_windows_sandbox_setup_status(&mut self) { - // While elevated sandbox setup runs, prevent typing so the user doesn't - // accidentally queue messages that will run under an unexpected mode. - self.bottom_pane.set_composer_input_enabled( - false, - Some("Input disabled until setup completes.".to_string()), - ); - self.bottom_pane.ensure_status_indicator(); - self.bottom_pane.set_interrupt_hint_visible(false); - self.set_status_header("Setting up agent sandbox. This can take a minute.".to_string()); - self.request_redraw(); - } - - #[cfg(not(target_os = "windows"))] - #[allow(dead_code)] - pub(crate) fn show_windows_sandbox_setup_status(&mut self) {} - - #[cfg(target_os = "windows")] - pub(crate) fn clear_windows_sandbox_setup_status(&mut self) { - self.bottom_pane.set_composer_input_enabled(true, None); - self.bottom_pane.hide_status_indicator(); - self.request_redraw(); - } - - #[cfg(not(target_os = "windows"))] - pub(crate) fn clear_windows_sandbox_setup_status(&mut self) {} - - #[cfg(target_os = "windows")] - pub(crate) fn clear_forced_auto_mode_downgrade(&mut self) { - self.config.forced_auto_mode_downgraded_on_windows = false; - } - - #[cfg(not(target_os = "windows"))] - #[allow(dead_code)] - pub(crate) fn clear_forced_auto_mode_downgrade(&mut self) {} - - /// Set the approval policy in the widget's config copy. - pub(crate) fn set_approval_policy(&mut self, policy: AskForApproval) { - if let Err(err) = self.config.approval_policy.set(policy) { - tracing::warn!(%err, "failed to set approval_policy on chat config"); - } - } - - /// Set the sandbox policy in the widget's config copy. - pub(crate) fn set_sandbox_policy(&mut self, policy: SandboxPolicy) -> ConstraintResult<()> { - #[cfg(target_os = "windows")] - let should_clear_downgrade = !matches!(&policy, SandboxPolicy::ReadOnly) - || codex_core::get_platform_sandbox().is_some(); - - self.config.sandbox_policy.set(policy)?; - - #[cfg(target_os = "windows")] - if should_clear_downgrade { - self.config.forced_auto_mode_downgraded_on_windows = false; - } - - Ok(()) - } - - #[cfg_attr(not(target_os = "windows"), allow(dead_code))] - pub(crate) fn set_feature_enabled(&mut self, feature: Feature, enabled: bool) { - if enabled { - self.config.features.enable(feature); - } else { - self.config.features.disable(feature); - } - if feature == Feature::Steer { - self.bottom_pane.set_steer_enabled(enabled); - } - if feature == Feature::CollaborationModes { - self.bottom_pane.set_collaboration_modes_enabled(enabled); - let settings = match &self.stored_collaboration_mode { - CollaborationMode::Plan(settings) - | CollaborationMode::PairProgramming(settings) - | CollaborationMode::Execute(settings) - | CollaborationMode::Custom(settings) => settings.clone(), - }; - self.stored_collaboration_mode = if enabled { - collaboration_modes::default_mode(self.models_manager.as_ref()) - .unwrap_or(CollaborationMode::Custom(settings)) - } else { - CollaborationMode::Custom(settings) - }; - } - } - - pub(crate) fn set_full_access_warning_acknowledged(&mut self, acknowledged: bool) { - self.config.notices.hide_full_access_warning = Some(acknowledged); - } - - pub(crate) fn set_world_writable_warning_acknowledged(&mut self, acknowledged: bool) { - self.config.notices.hide_world_writable_warning = Some(acknowledged); - } - - pub(crate) fn set_rate_limit_switch_prompt_hidden(&mut self, hidden: bool) { - self.config.notices.hide_rate_limit_model_nudge = Some(hidden); - if hidden { - self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle; - } - } - - #[cfg_attr(not(target_os = "windows"), allow(dead_code))] - pub(crate) fn world_writable_warning_hidden(&self) -> bool { - self.config - .notices - .hide_world_writable_warning - .unwrap_or(false) - } - - /// Set the reasoning effort in the stored collaboration mode. - pub(crate) fn set_reasoning_effort(&mut self, effort: Option) { - self.stored_collaboration_mode = - self.stored_collaboration_mode - .with_updates(None, Some(effort), None); - } - - /// Set the model in the widget's config copy and stored collaboration mode. - pub(crate) fn set_model(&mut self, model: &str) { - self.session_header.set_model(model); - self.stored_collaboration_mode = - self.stored_collaboration_mode - .with_updates(Some(model.to_string()), None, None); - } - - pub(crate) fn current_model(&self) -> &str { - self.stored_collaboration_mode.model() - } - - #[cfg(test)] - pub(crate) fn current_reasoning_effort(&self) -> Option { - self.stored_collaboration_mode.reasoning_effort() - } - - fn is_session_configured(&self) -> bool { - self.conversation_id.is_some() - } - - fn collaboration_modes_enabled(&self) -> bool { - self.config.features.enabled(Feature::CollaborationModes) - } - - fn model_display_name(&self) -> &str { - let model = self.current_model(); - if model.is_empty() { - DEFAULT_MODEL_DISPLAY_NAME - } else { - model - } - } - - /// Get the label for the current collaboration mode. - fn collaboration_mode_label(&self) -> Option<&'static str> { - if !self.collaboration_modes_enabled() { - return None; - } - match &self.stored_collaboration_mode { - CollaborationMode::Plan(_) => Some("Plan"), - CollaborationMode::PairProgramming(_) => Some("Pair Programming"), - CollaborationMode::Execute(_) => Some("Execute"), - CollaborationMode::Custom(_) => None, - } - } - - /// Cycle to the next collaboration mode variant (Plan -> PairProgramming -> Execute -> Plan). - fn cycle_collaboration_mode(&mut self) { - if !self.collaboration_modes_enabled() { - return; - } - - if let Some(next_mode) = collaboration_modes::next_mode( - self.models_manager.as_ref(), - &self.stored_collaboration_mode, - ) { - self.set_collaboration_mode(next_mode); - } - } - - /// Update the stored collaboration mode. - /// - /// When collaboration modes are enabled, the current mode is attached to *every* - /// submission as `Op::UserTurn { collaboration_mode: Some(...) }`. - pub(crate) fn set_collaboration_mode(&mut self, mode: CollaborationMode) { - if !self.collaboration_modes_enabled() { - return; - } - - self.stored_collaboration_mode = mode; - - let label = self.collaboration_mode_label(); - if let Some(label) = label { - let flash = Line::from(vec![ - label.bold(), - " (".dim(), - key_hint::shift(KeyCode::Tab).into(), - " to change mode)".dim(), - ]); - const FLASH_DURATION: Duration = Duration::from_secs(2); - self.bottom_pane.flash_footer_hint(flash, FLASH_DURATION); - } - self.request_redraw(); - } - - /// Build a placeholder header cell while the session is configuring. - fn placeholder_session_header_cell( - config: &Config, - is_collaboration: bool, - collaboration_mode: CollaborationMode, - ) -> Box { - let placeholder_style = Style::default().add_modifier(Modifier::DIM | Modifier::ITALIC); - Box::new(history_cell::SessionHeaderHistoryCell::new_with_style( - DEFAULT_MODEL_DISPLAY_NAME.to_string(), - placeholder_style, - None, - config.cwd.clone(), - CODEX_CLI_VERSION, - is_collaboration, - collaboration_mode, - )) - } - - /// Merge the real session info cell with any placeholder header to avoid double boxes. - fn apply_session_info_cell(&mut self, cell: history_cell::SessionInfoCell) { - let mut session_info_cell = Some(Box::new(cell) as Box); - let merged_header = if let Some(active) = self.active_cell.take() { - if active - .as_any() - .is::() - { - if let Some(cell) = session_info_cell.take() { - self.active_cell = Some(cell); - } - true - } else { - self.active_cell = Some(active); - false - } - } else { - false - }; - - self.flush_active_cell(); - - if !merged_header && let Some(cell) = session_info_cell { - self.add_boxed_history(cell); - } - } - - pub(crate) fn add_info_message(&mut self, message: String, hint: Option) { - self.add_to_history(history_cell::new_info_event(message, hint)); - self.request_redraw(); - } - - pub(crate) fn add_plain_history_lines(&mut self, lines: Vec>) { - self.add_boxed_history(Box::new(PlainHistoryCell::new(lines))); - self.request_redraw(); - } - - pub(crate) fn add_error_message(&mut self, message: String) { - self.add_to_history(history_cell::new_error_event(message)); - self.request_redraw(); - } - - pub(crate) fn add_mcp_output(&mut self) { - if self.config.mcp_servers.is_empty() { - self.add_to_history(history_cell::empty_mcp_output()); - } else { - self.submit_op(Op::ListMcpTools); - } - } - - /// Forward file-search results to the bottom pane. - pub(crate) fn apply_file_search_result(&mut self, query: String, matches: Vec) { - self.bottom_pane.on_file_search_result(query, matches); - } - - /// Handles a Ctrl+C press at the chat-widget layer. - /// - /// The first press arms a time-bounded quit shortcut and shows a footer hint via the bottom - /// pane. If cancellable work is active, Ctrl+C also submits `Op::Interrupt` after the shortcut - /// is armed. - /// - /// If the same quit shortcut is pressed again before expiry, this requests a shutdown-first - /// quit. - fn on_ctrl_c(&mut self) { - let key = key_hint::ctrl(KeyCode::Char('c')); - let modal_or_popup_active = !self.bottom_pane.no_modal_or_popup_active(); - if self.bottom_pane.on_ctrl_c() == CancellationEvent::Handled { - if DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED { - if modal_or_popup_active { - self.quit_shortcut_expires_at = None; - self.quit_shortcut_key = None; - self.bottom_pane.clear_quit_shortcut_hint(); - } else { - self.arm_quit_shortcut(key); - } - } - return; - } - - if !DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED { - if self.is_cancellable_work_active() { - self.submit_op(Op::Interrupt); - } else { - self.request_quit_without_confirmation(); - } - return; - } - - if self.quit_shortcut_active_for(key) { - self.quit_shortcut_expires_at = None; - self.quit_shortcut_key = None; - self.request_quit_without_confirmation(); - return; - } - - self.arm_quit_shortcut(key); - - if self.is_cancellable_work_active() { - self.submit_op(Op::Interrupt); - } - } - - /// Handles a Ctrl+D press at the chat-widget layer. - /// - /// Ctrl-D only participates in quit when the composer is empty and no modal/popup is active. - /// Otherwise it should be routed to the active view and not attempt to quit. - fn on_ctrl_d(&mut self) -> bool { - let key = key_hint::ctrl(KeyCode::Char('d')); - if !DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED { - if !self.bottom_pane.composer_is_empty() || !self.bottom_pane.no_modal_or_popup_active() - { - return false; - } - - self.request_quit_without_confirmation(); - return true; - } - - if self.quit_shortcut_active_for(key) { - self.quit_shortcut_expires_at = None; - self.quit_shortcut_key = None; - self.request_quit_without_confirmation(); - return true; - } - - if !self.bottom_pane.composer_is_empty() || !self.bottom_pane.no_modal_or_popup_active() { - return false; - } - - self.arm_quit_shortcut(key); - true - } - - /// True if `key` matches the armed quit shortcut and the window has not expired. - fn quit_shortcut_active_for(&self, key: KeyBinding) -> bool { - self.quit_shortcut_key == Some(key) - && self - .quit_shortcut_expires_at - .is_some_and(|expires_at| Instant::now() < expires_at) - } - - /// Arm the double-press quit shortcut and show the footer hint. - /// - /// This keeps the state machine (`quit_shortcut_*`) in `ChatWidget`, since - /// it is the component that interprets Ctrl+C vs Ctrl+D and decides whether - /// quitting is currently allowed, while delegating rendering to `BottomPane`. - fn arm_quit_shortcut(&mut self, key: KeyBinding) { - self.quit_shortcut_expires_at = Instant::now() - .checked_add(QUIT_SHORTCUT_TIMEOUT) - .or_else(|| Some(Instant::now())); - self.quit_shortcut_key = Some(key); - self.bottom_pane.show_quit_shortcut_hint(key); - } - - // Review mode counts as cancellable work so Ctrl+C interrupts instead of quitting. - fn is_cancellable_work_active(&self) -> bool { - self.bottom_pane.is_task_running() || self.is_review_mode - } - - pub(crate) fn composer_is_empty(&self) -> bool { - self.bottom_pane.composer_is_empty() - } - - /// True when the UI is in the regular composer state with no running task, - /// no modal overlay (e.g. approvals or status indicator), and no composer popups. - /// In this state Esc-Esc backtracking is enabled. - pub(crate) fn is_normal_backtrack_mode(&self) -> bool { - self.bottom_pane.is_normal_backtrack_mode() - } - - pub(crate) fn insert_str(&mut self, text: &str) { - self.bottom_pane.insert_str(text); - } - - /// Replace the composer content with the provided text and reset cursor. - pub(crate) fn set_composer_text( - &mut self, - text: String, - text_elements: Vec, - local_image_paths: Vec, - ) { - self.bottom_pane - .set_composer_text(text, text_elements, local_image_paths); - } - - pub(crate) fn show_esc_backtrack_hint(&mut self) { - self.bottom_pane.show_esc_backtrack_hint(); - } - - pub(crate) fn clear_esc_backtrack_hint(&mut self) { - self.bottom_pane.clear_esc_backtrack_hint(); - } - - /// Return true when the bottom pane currently has an active task. - /// - /// This is used by the viewport to decide when mouse selections should - /// disengage auto-follow behavior while responses are streaming. - pub(crate) fn is_task_running(&self) -> bool { - self.bottom_pane.is_task_running() - } - - /// Inform the bottom pane about the current transcript scroll state. - /// - /// This is used by the footer to surface when the inline transcript is - /// scrolled away from the bottom and to display the current - /// `(visible_top, total)` scroll position alongside other shortcuts. - pub(crate) fn set_transcript_ui_state( - &mut self, - scrolled: bool, - selection_active: bool, - scroll_position: Option<(usize, usize)>, - copy_selection_key: crate::key_hint::KeyBinding, - copy_feedback: Option, - ) { - self.bottom_pane.set_transcript_ui_state( - scrolled, - selection_active, - scroll_position, - copy_selection_key, - copy_feedback, - ); - } - - /// Forward an `Op` directly to codex. - pub(crate) fn submit_op(&self, op: Op) { - // Record outbound operation for session replay fidelity. - crate::session_log::log_outbound_op(&op); - if let Err(e) = self.codex_op_tx.send(op) { - tracing::error!("failed to submit op: {e}"); - } - } - - fn on_list_mcp_tools(&mut self, ev: McpListToolsResponseEvent) { - self.add_to_history(history_cell::new_mcp_tools_output( - &self.config, - ev.tools, - ev.resources, - ev.resource_templates, - &ev.auth_statuses, - )); - } - - fn on_list_custom_prompts(&mut self, ev: ListCustomPromptsResponseEvent) { - let len = ev.custom_prompts.len(); - debug!("received {len} custom prompts"); - // Forward to bottom pane so the slash popup can show them now. - self.bottom_pane.set_custom_prompts(ev.custom_prompts); - } - - fn on_list_skills(&mut self, ev: ListSkillsResponseEvent) { - self.set_skills_from_response(&ev); - } - - pub(crate) fn open_review_popup(&mut self) { - let mut items: Vec = Vec::new(); - - items.push(SelectionItem { - name: "Review against a base branch".to_string(), - description: Some("(PR Style)".into()), - actions: vec![Box::new({ - let cwd = self.config.cwd.clone(); - move |tx| { - tx.send(AppEvent::OpenReviewBranchPicker(cwd.clone())); - } - })], - dismiss_on_select: false, - ..Default::default() - }); - - items.push(SelectionItem { - name: "Review uncommitted changes".to_string(), - actions: vec![Box::new(move |tx: &AppEventSender| { - tx.send(AppEvent::CodexOp(Op::Review { - review_request: ReviewRequest { - target: ReviewTarget::UncommittedChanges, - user_facing_hint: None, - }, - })); - })], - dismiss_on_select: true, - ..Default::default() - }); - - // New: Review a specific commit (opens commit picker) - items.push(SelectionItem { - name: "Review a commit".to_string(), - actions: vec![Box::new({ - let cwd = self.config.cwd.clone(); - move |tx| { - tx.send(AppEvent::OpenReviewCommitPicker(cwd.clone())); - } - })], - dismiss_on_select: false, - ..Default::default() - }); - - items.push(SelectionItem { - name: "Custom review instructions".to_string(), - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::OpenReviewCustomPrompt); - })], - dismiss_on_select: false, - ..Default::default() - }); - - self.bottom_pane.show_selection_view(SelectionViewParams { - title: Some("Select a review preset".into()), - footer_hint: Some(standard_popup_hint_line()), - items, - ..Default::default() - }); - } - - pub(crate) async fn show_review_branch_picker(&mut self, cwd: &Path) { - let branches = local_git_branches(cwd).await; - let current_branch = current_branch_name(cwd) - .await - .unwrap_or_else(|| "(detached HEAD)".to_string()); - let mut items: Vec = Vec::with_capacity(branches.len()); - - for option in branches { - let branch = option.clone(); - items.push(SelectionItem { - name: format!("{current_branch} -> {branch}"), - actions: vec![Box::new(move |tx3: &AppEventSender| { - tx3.send(AppEvent::CodexOp(Op::Review { - review_request: ReviewRequest { - target: ReviewTarget::BaseBranch { - branch: branch.clone(), - }, - user_facing_hint: None, - }, - })); - })], - dismiss_on_select: true, - search_value: Some(option), - ..Default::default() - }); - } - - self.bottom_pane.show_selection_view(SelectionViewParams { - title: Some("Select a base branch".to_string()), - footer_hint: Some(standard_popup_hint_line()), - items, - is_searchable: true, - search_placeholder: Some("Type to search branches".to_string()), - ..Default::default() - }); - } - - pub(crate) async fn show_review_commit_picker(&mut self, cwd: &Path) { - let commits = codex_core::git_info::recent_commits(cwd, 100).await; - - let mut items: Vec = Vec::with_capacity(commits.len()); - for entry in commits { - let subject = entry.subject.clone(); - let sha = entry.sha.clone(); - let search_val = format!("{subject} {sha}"); - - items.push(SelectionItem { - name: subject.clone(), - actions: vec![Box::new(move |tx3: &AppEventSender| { - tx3.send(AppEvent::CodexOp(Op::Review { - review_request: ReviewRequest { - target: ReviewTarget::Commit { - sha: sha.clone(), - title: Some(subject.clone()), - }, - user_facing_hint: None, - }, - })); - })], - dismiss_on_select: true, - search_value: Some(search_val), - ..Default::default() - }); - } - - self.bottom_pane.show_selection_view(SelectionViewParams { - title: Some("Select a commit to review".to_string()), - footer_hint: Some(standard_popup_hint_line()), - items, - is_searchable: true, - search_placeholder: Some("Type to search commits".to_string()), - ..Default::default() - }); - } - - pub(crate) fn show_review_custom_prompt(&mut self) { - let tx = self.app_event_tx.clone(); - let view = CustomPromptView::new( - "Custom review instructions".to_string(), - "Type instructions and press Enter".to_string(), - None, - Box::new(move |prompt: String| { - let trimmed = prompt.trim().to_string(); - if trimmed.is_empty() { - return; - } - tx.send(AppEvent::CodexOp(Op::Review { - review_request: ReviewRequest { - target: ReviewTarget::Custom { - instructions: trimmed, - }, - user_facing_hint: None, - }, - })); - }), - ); - self.bottom_pane.show_view(Box::new(view)); - } - - pub(crate) fn token_usage(&self) -> TokenUsage { - self.token_info - .as_ref() - .map(|ti| ti.total_token_usage.clone()) - .unwrap_or_default() - } - - pub(crate) fn conversation_id(&self) -> Option { - self.conversation_id - } - - pub(crate) fn rollout_path(&self) -> Option { - self.current_rollout_path.clone() - } - - /// Returns a cache key describing the current in-flight active cell for the transcript overlay. - /// - /// `Ctrl+T` renders committed transcript cells plus a render-only live tail derived from the - /// current active cell, and the overlay caches that tail; this key is what it uses to decide - /// whether it must recompute. When there is no active cell, this returns `None` so the overlay - /// can drop the tail entirely. - /// - /// If callers mutate the active cell's transcript output without bumping the revision (or - /// providing an appropriate animation tick), the overlay will keep showing a stale tail while - /// the main viewport updates. - pub(crate) fn active_cell_transcript_key(&self) -> Option { - let cell = self.active_cell.as_ref()?; - Some(ActiveCellTranscriptKey { - revision: self.active_cell_revision, - is_stream_continuation: cell.is_stream_continuation(), - animation_tick: cell.transcript_animation_tick(), - }) - } - - /// Returns the active cell's transcript lines for a given terminal width. - /// - /// This is a convenience for the transcript overlay live-tail path, and it intentionally - /// filters out empty results so the overlay can treat "nothing to render" as "no tail". Callers - /// should pass the same width the overlay uses; using a different width will cause wrapping - /// mismatches between the main viewport and the transcript overlay. - pub(crate) fn active_cell_transcript_lines(&self, width: u16) -> Option>> { - let cell = self.active_cell.as_ref()?; - let lines = cell.transcript_lines(width); - (!lines.is_empty()).then_some(lines) - } - - /// Return a reference to the widget's current config (includes any - /// runtime overrides applied via TUI, e.g., model or approval policy). - pub(crate) fn config_ref(&self) -> &Config { - &self.config - } - - pub(crate) fn clear_token_usage(&mut self) { - self.token_info = None; - } - - fn as_renderable(&self) -> RenderableItem<'_> { - let active_cell_renderable = match &self.active_cell { - Some(cell) => RenderableItem::Borrowed(cell).inset(Insets::tlbr(1, 0, 0, 0)), - None => RenderableItem::Owned(Box::new(())), - }; - let mut flex = FlexRenderable::new(); - flex.push(1, active_cell_renderable); - flex.push( - 0, - RenderableItem::Borrowed(&self.bottom_pane).inset(Insets::tlbr(1, 0, 0, 0)), - ); - RenderableItem::Owned(Box::new(flex)) - } -} - -impl Drop for ChatWidget { - fn drop(&mut self) { - self.stop_rate_limit_poller(); - } -} - -impl Renderable for ChatWidget { - fn render(&self, area: Rect, buf: &mut Buffer) { - self.as_renderable().render(area, buf); - self.last_rendered_width.set(Some(area.width as usize)); - } - - fn desired_height(&self, width: u16) -> u16 { - self.as_renderable().desired_height(width) - } - - fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { - self.as_renderable().cursor_pos(area) - } -} - -enum Notification { - AgentTurnComplete { response: String }, - ExecApprovalRequested { command: String }, - EditApprovalRequested { cwd: PathBuf, changes: Vec }, - ElicitationRequested { server_name: String }, -} - -impl Notification { - fn display(&self) -> String { - match self { - Notification::AgentTurnComplete { response } => { - Notification::agent_turn_preview(response) - .unwrap_or_else(|| "Agent turn complete".to_string()) - } - Notification::ExecApprovalRequested { command } => { - format!("Approval requested: {}", truncate_text(command, 30)) - } - Notification::EditApprovalRequested { cwd, changes } => { - format!( - "Codex wants to edit {}", - if changes.len() == 1 { - #[allow(clippy::unwrap_used)] - display_path_for(changes.first().unwrap(), cwd) - } else { - format!("{} files", changes.len()) - } - ) - } - Notification::ElicitationRequested { server_name } => { - format!("Approval requested by {server_name}") - } - } - } - - fn type_name(&self) -> &str { - match self { - Notification::AgentTurnComplete { .. } => "agent-turn-complete", - Notification::ExecApprovalRequested { .. } - | Notification::EditApprovalRequested { .. } - | Notification::ElicitationRequested { .. } => "approval-requested", - } - } - - fn allowed_for(&self, settings: &Notifications) -> bool { - match settings { - Notifications::Enabled(enabled) => *enabled, - Notifications::Custom(allowed) => allowed.iter().any(|a| a == self.type_name()), - } - } - - fn agent_turn_preview(response: &str) -> Option { - let mut normalized = String::new(); - for part in response.split_whitespace() { - if !normalized.is_empty() { - normalized.push(' '); - } - normalized.push_str(part); - } - let trimmed = normalized.trim(); - if trimmed.is_empty() { - None - } else { - Some(truncate_text(trimmed, AGENT_NOTIFICATION_PREVIEW_GRAPHEMES)) - } - } -} - -const AGENT_NOTIFICATION_PREVIEW_GRAPHEMES: usize = 200; - -const PLACEHOLDERS: [&str; 8] = [ - "Explain this codebase", - "Summarize recent commits", - "Implement {feature}", - "Find and fix a bug in @filename", - "Write tests for @filename", - "Improve documentation in @filename", - "Run /review on my current changes", - "Use /skills to list available skills", -]; - -// Extract the first bold (Markdown) element in the form **...** from `s`. -// Returns the inner text if found; otherwise `None`. -fn extract_first_bold(s: &str) -> Option { - let bytes = s.as_bytes(); - let mut i = 0usize; - while i + 1 < bytes.len() { - if bytes[i] == b'*' && bytes[i + 1] == b'*' { - let start = i + 2; - let mut j = start; - while j + 1 < bytes.len() { - if bytes[j] == b'*' && bytes[j + 1] == b'*' { - // Found closing ** - let inner = &s[start..j]; - let trimmed = inner.trim(); - if !trimmed.is_empty() { - return Some(trimmed.to_string()); - } else { - return None; - } - } - j += 1; - } - // No closing; stop searching (wait for more deltas) - return None; - } - i += 1; - } - None -} - -async fn fetch_rate_limits(base_url: String, auth: CodexAuth) -> Option { - match BackendClient::from_auth(base_url, &auth) { - Ok(client) => match client.get_rate_limits().await { - Ok(snapshot) => Some(snapshot), - Err(err) => { - debug!(error = ?err, "failed to fetch rate limits from /usage"); - None - } - }, - Err(err) => { - debug!(error = ?err, "failed to construct backend client for rate limits"); - None - } - } -} - -#[cfg(test)] -pub(crate) fn show_review_commit_picker_with_entries( - chat: &mut ChatWidget, - entries: Vec, -) { - let mut items: Vec = Vec::with_capacity(entries.len()); - for entry in entries { - let subject = entry.subject.clone(); - let sha = entry.sha.clone(); - let search_val = format!("{subject} {sha}"); - - items.push(SelectionItem { - name: subject.clone(), - actions: vec![Box::new(move |tx3: &AppEventSender| { - tx3.send(AppEvent::CodexOp(Op::Review { - review_request: ReviewRequest { - target: ReviewTarget::Commit { - sha: sha.clone(), - title: Some(subject.clone()), - }, - user_facing_hint: None, - }, - })); - })], - dismiss_on_select: true, - search_value: Some(search_val), - ..Default::default() - }); - } - - chat.bottom_pane.show_selection_view(SelectionViewParams { - title: Some("Select a commit to review".to_string()), - footer_hint: Some(standard_popup_hint_line()), - items, - is_searchable: true, - search_placeholder: Some("Type to search commits".to_string()), - ..Default::default() - }); -} - -fn find_skill_mentions(text: &str, skills: &[SkillMetadata]) -> Vec { - let mut seen: HashSet = HashSet::new(); - let mut matches: Vec = Vec::new(); - for skill in skills { - if seen.contains(&skill.name) { - continue; - } - let needle = format!("${}", skill.name); - if text.contains(&needle) { - seen.insert(skill.name.clone()); - matches.push(skill.clone()); - } - } - matches -} - -fn skills_for_cwd(cwd: &Path, skills_entries: &[SkillsListEntry]) -> Vec { - skills_entries - .iter() - .find(|entry| entry.cwd.as_path() == cwd) - .map(|entry| { - entry - .skills - .iter() - .map(|skill| SkillMetadata { - name: skill.name.clone(), - description: skill.description.clone(), - short_description: skill.short_description.clone(), - interface: skill.interface.clone().map(|interface| SkillInterface { - display_name: interface.display_name, - short_description: interface.short_description, - icon_small: interface.icon_small, - icon_large: interface.icon_large, - brand_color: interface.brand_color, - default_prompt: interface.default_prompt, - }), - path: skill.path.clone(), - scope: skill.scope, - }) - .collect() - }) - .unwrap_or_default() -} - -#[cfg(test)] -pub(crate) mod tests; diff --git a/codex-rs/tui2/src/chatwidget/agent.rs b/codex-rs/tui2/src/chatwidget/agent.rs deleted file mode 100644 index 0cfda7f3429..00000000000 --- a/codex-rs/tui2/src/chatwidget/agent.rs +++ /dev/null @@ -1,107 +0,0 @@ -use std::sync::Arc; - -use codex_core::CodexThread; -use codex_core::NewThread; -use codex_core::ThreadManager; -use codex_core::config::Config; -use codex_core::protocol::Event; -use codex_core::protocol::EventMsg; -use codex_core::protocol::Op; -use tokio::sync::mpsc::UnboundedSender; -use tokio::sync::mpsc::unbounded_channel; - -use crate::app_event::AppEvent; -use crate::app_event_sender::AppEventSender; - -/// Spawn the agent bootstrapper and op forwarding loop, returning the -/// `UnboundedSender` used by the UI to submit operations. -pub(crate) fn spawn_agent( - config: Config, - app_event_tx: AppEventSender, - server: Arc, -) -> UnboundedSender { - let (codex_op_tx, mut codex_op_rx) = unbounded_channel::(); - - let app_event_tx_clone = app_event_tx; - tokio::spawn(async move { - let NewThread { - thread, - session_configured, - thread_id: _, - } = match server.start_thread(config).await { - Ok(v) => v, - Err(err) => { - let message = format!("Failed to initialize codex: {err}"); - tracing::error!("{message}"); - app_event_tx_clone.send(AppEvent::CodexEvent(Event { - id: "".to_string(), - msg: EventMsg::Error(err.to_error_event(None)), - })); - app_event_tx_clone.send(AppEvent::FatalExitRequest(message)); - tracing::error!("failed to initialize codex: {err}"); - return; - } - }; - - // Forward the captured `SessionConfigured` event so it can be rendered in the UI. - let ev = codex_core::protocol::Event { - // The `id` does not matter for rendering, so we can use a fake value. - id: "".to_string(), - msg: codex_core::protocol::EventMsg::SessionConfigured(session_configured), - }; - app_event_tx_clone.send(AppEvent::CodexEvent(ev)); - - let thread_clone = thread.clone(); - tokio::spawn(async move { - while let Some(op) = codex_op_rx.recv().await { - let id = thread_clone.submit(op).await; - if let Err(e) = id { - tracing::error!("failed to submit op: {e}"); - } - } - }); - - while let Ok(event) = thread.next_event().await { - app_event_tx_clone.send(AppEvent::CodexEvent(event)); - } - }); - - codex_op_tx -} - -/// Spawn agent loops for an existing thread (e.g., a forked thread). -/// Sends the provided `SessionConfiguredEvent` immediately, then forwards subsequent -/// events and accepts Ops for submission. -pub(crate) fn spawn_agent_from_existing( - thread: std::sync::Arc, - session_configured: codex_core::protocol::SessionConfiguredEvent, - app_event_tx: AppEventSender, -) -> UnboundedSender { - let (codex_op_tx, mut codex_op_rx) = unbounded_channel::(); - - let app_event_tx_clone = app_event_tx; - tokio::spawn(async move { - // Forward the captured `SessionConfigured` event so it can be rendered in the UI. - let ev = codex_core::protocol::Event { - id: "".to_string(), - msg: codex_core::protocol::EventMsg::SessionConfigured(session_configured), - }; - app_event_tx_clone.send(AppEvent::CodexEvent(ev)); - - let thread_clone = thread.clone(); - tokio::spawn(async move { - while let Some(op) = codex_op_rx.recv().await { - let id = thread_clone.submit(op).await; - if let Err(e) = id { - tracing::error!("failed to submit op: {e}"); - } - } - }); - - while let Ok(event) = thread.next_event().await { - app_event_tx_clone.send(AppEvent::CodexEvent(event)); - } - }); - - codex_op_tx -} diff --git a/codex-rs/tui2/src/chatwidget/interrupts.rs b/codex-rs/tui2/src/chatwidget/interrupts.rs deleted file mode 100644 index dc1e683ea55..00000000000 --- a/codex-rs/tui2/src/chatwidget/interrupts.rs +++ /dev/null @@ -1,96 +0,0 @@ -use std::collections::VecDeque; - -use codex_core::protocol::ApplyPatchApprovalRequestEvent; -use codex_core::protocol::ExecApprovalRequestEvent; -use codex_core::protocol::ExecCommandBeginEvent; -use codex_core::protocol::ExecCommandEndEvent; -use codex_core::protocol::McpToolCallBeginEvent; -use codex_core::protocol::McpToolCallEndEvent; -use codex_core::protocol::PatchApplyEndEvent; -use codex_protocol::approvals::ElicitationRequestEvent; - -use super::ChatWidget; - -#[derive(Debug)] -pub(crate) enum QueuedInterrupt { - ExecApproval(String, ExecApprovalRequestEvent), - ApplyPatchApproval(String, ApplyPatchApprovalRequestEvent), - Elicitation(ElicitationRequestEvent), - ExecBegin(ExecCommandBeginEvent), - ExecEnd(ExecCommandEndEvent), - McpBegin(McpToolCallBeginEvent), - McpEnd(McpToolCallEndEvent), - PatchEnd(PatchApplyEndEvent), -} - -#[derive(Default)] -pub(crate) struct InterruptManager { - queue: VecDeque, -} - -impl InterruptManager { - pub(crate) fn new() -> Self { - Self { - queue: VecDeque::new(), - } - } - - #[inline] - pub(crate) fn is_empty(&self) -> bool { - self.queue.is_empty() - } - - pub(crate) fn push_exec_approval(&mut self, id: String, ev: ExecApprovalRequestEvent) { - self.queue.push_back(QueuedInterrupt::ExecApproval(id, ev)); - } - - pub(crate) fn push_apply_patch_approval( - &mut self, - id: String, - ev: ApplyPatchApprovalRequestEvent, - ) { - self.queue - .push_back(QueuedInterrupt::ApplyPatchApproval(id, ev)); - } - - pub(crate) fn push_elicitation(&mut self, ev: ElicitationRequestEvent) { - self.queue.push_back(QueuedInterrupt::Elicitation(ev)); - } - - pub(crate) fn push_exec_begin(&mut self, ev: ExecCommandBeginEvent) { - self.queue.push_back(QueuedInterrupt::ExecBegin(ev)); - } - - pub(crate) fn push_exec_end(&mut self, ev: ExecCommandEndEvent) { - self.queue.push_back(QueuedInterrupt::ExecEnd(ev)); - } - - pub(crate) fn push_mcp_begin(&mut self, ev: McpToolCallBeginEvent) { - self.queue.push_back(QueuedInterrupt::McpBegin(ev)); - } - - pub(crate) fn push_mcp_end(&mut self, ev: McpToolCallEndEvent) { - self.queue.push_back(QueuedInterrupt::McpEnd(ev)); - } - - pub(crate) fn push_patch_end(&mut self, ev: PatchApplyEndEvent) { - self.queue.push_back(QueuedInterrupt::PatchEnd(ev)); - } - - pub(crate) fn flush_all(&mut self, chat: &mut ChatWidget) { - while let Some(q) = self.queue.pop_front() { - match q { - QueuedInterrupt::ExecApproval(id, ev) => chat.handle_exec_approval_now(id, ev), - QueuedInterrupt::ApplyPatchApproval(id, ev) => { - chat.handle_apply_patch_approval_now(id, ev) - } - QueuedInterrupt::Elicitation(ev) => chat.handle_elicitation_request_now(ev), - QueuedInterrupt::ExecBegin(ev) => chat.handle_exec_begin_now(ev), - QueuedInterrupt::ExecEnd(ev) => chat.handle_exec_end_now(ev), - QueuedInterrupt::McpBegin(ev) => chat.handle_mcp_begin_now(ev), - QueuedInterrupt::McpEnd(ev) => chat.handle_mcp_end_now(ev), - QueuedInterrupt::PatchEnd(ev) => chat.handle_patch_apply_end_now(ev), - } - } - } -} diff --git a/codex-rs/tui2/src/chatwidget/session_header.rs b/codex-rs/tui2/src/chatwidget/session_header.rs deleted file mode 100644 index 32e31b6682e..00000000000 --- a/codex-rs/tui2/src/chatwidget/session_header.rs +++ /dev/null @@ -1,16 +0,0 @@ -pub(crate) struct SessionHeader { - model: String, -} - -impl SessionHeader { - pub(crate) fn new(model: String) -> Self { - Self { model } - } - - /// Updates the header's model text. - pub(crate) fn set_model(&mut self, model: &str) { - if self.model != model { - self.model = model.to_string(); - } - } -} diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__apply_patch_manual_flow_history_approved.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__apply_patch_manual_flow_history_approved.snap deleted file mode 100644 index 26c7f587096..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__apply_patch_manual_flow_history_approved.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: lines_to_single_string(&approved_lines) ---- -• Added foo.txt (+1 -0) - 1 +hello diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_exec.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_exec.snap deleted file mode 100644 index c69730b4833..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_exec.snap +++ /dev/null @@ -1,15 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: terminal.backend().vt100().screen().contents() ---- - Would you like to run the following command? - - Reason: this is a test reason such as one that would be produced by the model - - $ echo hello world - -› 1. Yes, proceed (y) - 2. Yes, and don't ask again for commands that start with `echo hello world` (p) - 3. No, and tell Codex what to do differently (esc) - - Press enter to confirm or esc to cancel diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_exec_multiline_prefix_no_execpolicy.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_exec_multiline_prefix_no_execpolicy.snap deleted file mode 100644 index 962a38ebde6..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_exec_multiline_prefix_no_execpolicy.snap +++ /dev/null @@ -1,16 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: contents ---- - - - Would you like to run the following command? - - $ python - <<'PY' - print('hello') - PY - -› 1. Yes, proceed (y) - 2. No, and tell Codex what to do differently (esc) - - Press enter to confirm or esc to cancel diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_exec_no_reason.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_exec_no_reason.snap deleted file mode 100644 index ab469f34b6a..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_exec_no_reason.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: terminal.backend().vt100().screen().contents() ---- - Would you like to run the following command? - - $ echo hello world - -› 1. Yes, proceed (y) - 2. Yes, and don't ask again for commands that start with `echo hello world` (p) - 3. No, and tell Codex what to do differently (esc) - - Press enter to confirm or esc to cancel diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_patch.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_patch.snap deleted file mode 100644 index c2ec1675066..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_patch.snap +++ /dev/null @@ -1,20 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: terminal.backend().vt100().screen().contents() ---- - - - Would you like to make the following edits? - - Reason: The model wants to apply changes - - README.md (+2 -0) - - 1 +hello - 2 +world - -› 1. Yes, proceed (y) - 2. Yes, and don't ask again for these files (a) - 3. No, and tell Codex what to do differently (esc) - - Press enter to confirm or esc to cancel diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup.snap deleted file mode 100644 index 46ec74d1179..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: popup ---- - Select Approval Mode - -› 1. Read Only (current) Requires approval to edit files and run commands. - 2. Agent Read and edit files, and run commands. - 3. Agent (full access) Codex can edit files outside this workspace and run - commands with network access. Exercise caution when - using. - - Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup@windows.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup@windows.snap deleted file mode 100644 index 467c56a0fdb..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup@windows.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -assertion_line: 2370 -expression: popup ---- - Select Approval Mode - -› 1. Read Only (current) Requires approval to edit files and run commands. - 2. Agent Read and edit files, and run commands. - 3. Agent (full access) Codex can edit files outside this workspace and run - commands with network access. Exercise caution when - using. - - Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup@windows_degraded.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup@windows_degraded.snap deleted file mode 100644 index bd6b8343edd..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup@windows_degraded.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -assertion_line: 1773 -expression: popup ---- - Select Approval Mode - -› 1. Read Only (current) Requires approval to edit files and run - commands. - 2. Agent (non-elevated sandbox) Read and edit files, and run commands. - 3. Agent (full access) Codex can edit files outside this workspace - and run commands with network access. - Exercise caution when using. - - The non-elevated sandbox protects your files and prevents network access under - most circumstances. However, it carries greater risk if prompt injected. To - upgrade to the elevated sandbox, run /setup-elevated-sandbox. - Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_idle_h1.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_idle_h1.snap deleted file mode 100644 index 8900e83d9af..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_idle_h1.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: terminal.backend() ---- -" " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_idle_h2.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_idle_h2.snap deleted file mode 100644 index a2afe14dfa0..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_idle_h2.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: terminal.backend() ---- -" " -" " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_idle_h3.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_idle_h3.snap deleted file mode 100644 index 1b285fb8108..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_idle_h3.snap +++ /dev/null @@ -1,7 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: terminal.backend() ---- -" " -" " -" " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_running_h1.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_running_h1.snap deleted file mode 100644 index 8900e83d9af..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_running_h1.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: terminal.backend() ---- -" " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_running_h2.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_running_h2.snap deleted file mode 100644 index a2afe14dfa0..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_running_h2.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: terminal.backend() ---- -" " -" " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_running_h3.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_running_h3.snap deleted file mode 100644 index 1b285fb8108..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_running_h3.snap +++ /dev/null @@ -1,7 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: terminal.backend() ---- -" " -" " -" " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap deleted file mode 100644 index c7d55357233..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap +++ /dev/null @@ -1,44 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: term.backend().vt100().screen().contents() ---- - - - - - - - - - - - - - - - - - - - - - - - - - - - -• I’m going to search the repo for where “Change Approved” is rendered to update - that view. - -• Explored - └ Search Change Approved - Read diff_render.rs - -• Investigating rendering code (0s • esc to interrupt) - - -› Summarize recent commits - - 100% context left · tab to queue message diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap deleted file mode 100644 index 9ab9b033808..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: term.backend().vt100().screen().contents() ---- -• -- Indented code block (4 spaces) - SELECT * - FROM "users" - WHERE "email" LIKE '%@example.com'; - - ```sh - printf 'fenced within fenced\n' - ``` - - { - // comment allowed in jsonc - "path": "C:\\Program Files\\App", - "regex": "^foo.*(bar)?$" - } diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_tall.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_tall.snap deleted file mode 100644 index 57a590c46c7..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_tall.snap +++ /dev/null @@ -1,28 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: term.backend().vt100().screen().contents() ---- - -• Working (0s • esc to interrupt) - - ↳ Hello, world! 0 - ↳ Hello, world! 1 - ↳ Hello, world! 2 - ↳ Hello, world! 3 - ↳ Hello, world! 4 - ↳ Hello, world! 5 - ↳ Hello, world! 6 - ↳ Hello, world! 7 - ↳ Hello, world! 8 - ↳ Hello, world! 9 - ↳ Hello, world! 10 - ↳ Hello, world! 11 - ↳ Hello, world! 12 - ↳ Hello, world! 13 - ↳ Hello, world! 14 - ↳ Hello, world! 15 - ↳ Hello, world! 16 - -› Ask Codex to do anything - - 100% context left · ? for shortcuts diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap deleted file mode 100644 index 3d83bdb0f5a..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: combined ---- -• Here is the result. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__disabled_slash_command_while_task_running_snapshot.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__disabled_slash_command_while_task_running_snapshot.snap deleted file mode 100644 index 6d252a0d3e3..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__disabled_slash_command_while_task_running_snapshot.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: blob ---- -■ '/model' is disabled while a task is in progress. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_history_decision_aborted_long.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_history_decision_aborted_long.snap deleted file mode 100644 index 50c08287731..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_history_decision_aborted_long.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: lines_to_single_string(&aborted_long) ---- -✗ You canceled the request to run echo - aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa... diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_history_decision_aborted_multiline.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_history_decision_aborted_multiline.snap deleted file mode 100644 index d7e1e2ac3a0..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_history_decision_aborted_multiline.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: lines_to_single_string(&aborted_multi) ---- -✗ You canceled the request to run echo line1 ... diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_history_decision_approved_short.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_history_decision_approved_short.snap deleted file mode 100644 index 2d3767dffb6..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_history_decision_approved_short.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: lines_to_single_string(&decision) ---- -✔ You approved codex to run echo hello world this time diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_modal_exec.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_modal_exec.snap deleted file mode 100644 index 93451be714d..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_modal_exec.snap +++ /dev/null @@ -1,36 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: "format!(\"{buf:?}\")" ---- -Buffer { - area: Rect { x: 0, y: 0, width: 80, height: 13 }, - content: [ - " ", - " ", - " Would you like to run the following command? ", - " ", - " Reason: this is a test reason such as one that would be produced by the ", - " model ", - " ", - " $ echo hello world ", - " ", - "› 1. Yes, proceed (y) ", - " 2. No, and tell Codex what to do differently (esc) ", - " ", - " Press enter to confirm or esc to cancel ", - ], - styles: [ - x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 2, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, - x: 46, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 10, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: ITALIC, - x: 73, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 2, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: ITALIC, - x: 7, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 9, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, - x: 21, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 48, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 51, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 2, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - ] -} diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step1_start_ls.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step1_start_ls.snap deleted file mode 100644 index 7a20304601e..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step1_start_ls.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: active_blob(&chat) ---- -• Exploring - └ List ls -la diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step2_finish_ls.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step2_finish_ls.snap deleted file mode 100644 index b13ce510e00..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step2_finish_ls.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: active_blob(&chat) ---- -• Explored - └ List ls -la diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step3_start_cat_foo.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step3_start_cat_foo.snap deleted file mode 100644 index ab15a80ff37..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step3_start_cat_foo.snap +++ /dev/null @@ -1,7 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: active_blob(&chat) ---- -• Exploring - └ List ls -la - Read foo.txt diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step4_finish_cat_foo.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step4_finish_cat_foo.snap deleted file mode 100644 index 21b41860fc9..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step4_finish_cat_foo.snap +++ /dev/null @@ -1,7 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: active_blob(&chat) ---- -• Explored - └ List ls -la - Read foo.txt diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step5_finish_sed_range.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step5_finish_sed_range.snap deleted file mode 100644 index 21b41860fc9..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step5_finish_sed_range.snap +++ /dev/null @@ -1,7 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: active_blob(&chat) ---- -• Explored - └ List ls -la - Read foo.txt diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step6_finish_cat_bar.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step6_finish_cat_bar.snap deleted file mode 100644 index a38d4c7fd22..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step6_finish_cat_bar.snap +++ /dev/null @@ -1,7 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: active_blob(&chat) ---- -• Explored - └ List ls -la - Read foo.txt, bar.txt diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__feedback_selection_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__feedback_selection_popup.snap deleted file mode 100644 index 52ce03bbea3..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__feedback_selection_popup.snap +++ /dev/null @@ -1,11 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: popup ---- - How was this? - -› 1. bug Crash, error message, hang, or broken UI/behavior. - 2. bad result Output was off-target, incorrect, incomplete, or unhelpful. - 3. good result Helpful, correct, high‑quality, or delightful result worth - celebrating. - 4. other Slowness, feature suggestion, UX feedback, or anything else. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__feedback_upload_consent_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__feedback_upload_consent_popup.snap deleted file mode 100644 index 21d031df6c8..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__feedback_upload_consent_popup.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: popup ---- - Upload logs? - - The following files will be sent: - • codex-logs.log - -› 1. Yes Share the current Codex session logs with the team for - troubleshooting. - 2. No - - Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap deleted file mode 100644 index 3d83bdb0f5a..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: combined ---- -• Here is the result. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__full_access_confirmation_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__full_access_confirmation_popup.snap deleted file mode 100644 index f7a2b3dcb66..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__full_access_confirmation_popup.snap +++ /dev/null @@ -1,15 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: popup ---- - Enable full access? - When Codex runs with full access, it can edit any file on your computer and - run commands with network, without your approval. Exercise caution when - enabling full access. This significantly increases the risk of data loss, - leaks, or unexpected behavior. - -› 1. Yes, continue anyway Apply full access for this session - 2. Yes, and don't ask again Enable full access and remember this choice - 3. Cancel Go back without enabling full access - - Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__interrupt_exec_marks_failed.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__interrupt_exec_marks_failed.snap deleted file mode 100644 index 3863f9a8d50..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__interrupt_exec_marks_failed.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: exec_blob ---- -• Ran sleep 1 - └ (no output) diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__interrupted_turn_error_message.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__interrupted_turn_error_message.snap deleted file mode 100644 index 943fe344402..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__interrupted_turn_error_message.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: last ---- -■ Conversation interrupted - tell the model what to do differently. Something went wrong? Hit `/feedback` to report the issue. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__local_image_attachment_history_snapshot.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__local_image_attachment_history_snapshot.snap deleted file mode 100644 index 31c5e74b0a3..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__local_image_attachment_history_snapshot.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: combined ---- -• Viewed Image - └ example.png diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__mcp_startup_header_booting.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__mcp_startup_header_booting.snap deleted file mode 100644 index e16f50ba1b9..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__mcp_startup_header_booting.snap +++ /dev/null @@ -1,11 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: terminal.backend() ---- -" " -"• Booting MCP server: alpha (0s • esc to interrupt) " -" " -" " -"› Ask Codex to do anything " -" " -" 100% context left · ? for shortcuts " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_picker_filters_hidden_models.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_picker_filters_hidden_models.snap deleted file mode 100644 index a03d434905f..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_picker_filters_hidden_models.snap +++ /dev/null @@ -1,11 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -assertion_line: 1758 -expression: popup ---- - Select Model and Effort - Access legacy models by running codex -m or in your config.toml - -› 1. test-visible-model (current) test-visible-model description - - Press enter to select reasoning effort, or esc to dismiss. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_reasoning_selection_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_reasoning_selection_popup.snap deleted file mode 100644 index 86c748d5972..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_reasoning_selection_popup.snap +++ /dev/null @@ -1,12 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: popup ---- - Select Reasoning Level for gpt-5.1-codex-max - - 1. Low Fast responses with lighter reasoning - 2. Medium (default) Balances speed and reasoning depth for everyday tasks -› 3. High (current) Greater reasoning depth for complex problems - 4. Extra high Extra high reasoning depth for complex problems - - Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_reasoning_selection_popup_extra_high_warning.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_reasoning_selection_popup_extra_high_warning.snap deleted file mode 100644 index 4d1d62d935a..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_reasoning_selection_popup_extra_high_warning.snap +++ /dev/null @@ -1,15 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: popup ---- - Select Reasoning Level for gpt-5.1-codex-max - - 1. Low Fast responses with lighter reasoning - 2. Medium (default) Balances speed and reasoning depth for everyday - tasks - 3. High Greater reasoning depth for complex problems -› 4. Extra high (current) Extra high reasoning depth for complex problems - ⚠ Extra high reasoning effort can quickly consume - Plus plan rate limits. - - Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_selection_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_selection_popup.snap deleted file mode 100644 index 27479f97f0f..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_selection_popup.snap +++ /dev/null @@ -1,16 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: popup ---- - Select Model and Effort - Access legacy models by running codex -m or in your config.toml - -› 1. gpt-5.2-codex (default) Latest frontier agentic coding model. - 2. gpt-5.2 Latest frontier model with improvements across - knowledge, reasoning and coding - 3. gpt-5.1-codex-max Codex-optimized flagship for deep and fast - reasoning. - 4. gpt-5.1-codex-mini Optimized for codex. Cheaper, faster, but less - capable. - - Press enter to select reasoning effort, or esc to dismiss. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__rate_limit_switch_prompt_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__rate_limit_switch_prompt_popup.snap deleted file mode 100644 index d553957350e..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__rate_limit_switch_prompt_popup.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: popup ---- - Approaching rate limits - Switch to gpt-5.1-codex-mini for lower credit usage? - -› 1. Switch to gpt-5.1-codex-mini Optimized for codex. Cheaper, - faster, but less capable. - 2. Keep current model - 3. Keep current model (never show again) Hide future rate limit reminders - about switching models. - - Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__status_widget_active.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__status_widget_active.snap deleted file mode 100644 index f761e5730bc..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__status_widget_active.snap +++ /dev/null @@ -1,11 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: terminal.backend() ---- -" " -"• Analyzing (0s • esc to interrupt) " -" " -" " -"› Ask Codex to do anything " -" " -" 100% context left · ? for shortcuts " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__status_widget_and_approval_modal.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__status_widget_and_approval_modal.snap deleted file mode 100644 index 567794cea6c..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__status_widget_and_approval_modal.snap +++ /dev/null @@ -1,17 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: terminal.backend() ---- -" " -" " -" Would you like to run the following command? " -" " -" Reason: this is a test reason such as one that would be produced by the model " -" " -" $ echo 'hello world' " -" " -"› 1. Yes, proceed (y) " -" 2. Yes, and don't ask again for commands that start with `echo 'hello world'` (p) " -" 3. No, and tell Codex what to do differently (esc) " -" " -" Press enter to confirm or esc to cancel " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__user_shell_ls_output.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__user_shell_ls_output.snap deleted file mode 100644 index 3a9f08ab94a..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__user_shell_ls_output.snap +++ /dev/null @@ -1,7 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: blob ---- -• You ran ls - └ file1 - file2 diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__apply_patch_manual_flow_history_approved.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__apply_patch_manual_flow_history_approved.snap deleted file mode 100644 index e139b510881..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__apply_patch_manual_flow_history_approved.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: lines_to_single_string(&approved_lines) ---- -• Added foo.txt (+1 -0) - 1 +hello diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap deleted file mode 100644 index 15511611a10..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap +++ /dev/null @@ -1,17 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: terminal.backend().vt100().screen().contents() ---- - - - Would you like to run the following command? - - Reason: this is a test reason such as one that would be produced by the model - - $ echo hello world - -› 1. Yes, proceed (y) - 2. Yes, and don't ask again for commands that start with `echo hello world` (p) - 3. No, and tell Codex what to do differently (esc) - - Press enter to confirm or esc to cancel diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap deleted file mode 100644 index 2bbe9aefcdf..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: terminal.backend().vt100().screen().contents() ---- - Would you like to run the following command? - - $ echo hello world - -› 1. Yes, proceed (y) - 2. Yes, and don't ask again for commands that start with `echo hello world` (p) - 3. No, and tell Codex what to do differently (esc) - - Press enter to confirm or esc to cancel diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap deleted file mode 100644 index e394605dcc5..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap +++ /dev/null @@ -1,20 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: terminal.backend().vt100().screen().contents() ---- - - - Would you like to make the following edits? - - Reason: The model wants to apply changes - - README.md (+2 -0) - - 1 +hello - 2 +world - -› 1. Yes, proceed (y) - 2. Yes, and don't ask again for these files (a) - 3. No, and tell Codex what to do differently (esc) - - Press enter to confirm or esc to cancel diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup.snap deleted file mode 100644 index 6758ec62c57..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: popup ---- - Select Approval Mode - -› 1. Read Only (current) Requires approval to edit files and run commands. - 2. Agent Read and edit files, and run commands. - 3. Agent (full access) Codex can edit files outside this workspace and run - commands with network access. Exercise caution when - using. - - Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows.snap deleted file mode 100644 index 6758ec62c57..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: popup ---- - Select Approval Mode - -› 1. Read Only (current) Requires approval to edit files and run commands. - 2. Agent Read and edit files, and run commands. - 3. Agent (full access) Codex can edit files outside this workspace and run - commands with network access. Exercise caution when - using. - - Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap deleted file mode 100644 index 77738439a17..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap +++ /dev/null @@ -1,153 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: "lines[start_idx..].join(\"\\n\")" ---- -• I need to check the codex-rs repository to explain why the project's binaries - are large. The user is likely seeking specifics about the setup: are Rust - builds static, what features are enabled, and is debug information included? - It could be due to static linking, included OpenSSL, or how panic handling - is set up. I should look into the Cargo.toml file to confirm features and - profiles without needing to edit any code. Let's get started on this! - -─ Worked for 0s ──────────────────────────────────────────────────────────────── - -• I’m going to scan the workspace and Cargo manifests to see build profiles and - dependencies that impact binary size. Then I’ll summarize the main causes. - -• Explored - └ List ls -la - Read Cargo.toml - -• I'm reviewing the workspace's release profile, which has settings like - lto=fat, strip=symbols, and codegen-units=1 to reduce binary size. However, it - seems that "strip = symbols" only removes symbols, not debug information. In - Rust 1.73+, both "symbols" and "debuginfo" stripping options are available. I - should check whether using these results in a smaller binary. I’ll also skim - through crates for heavy dependencies in Cargo.toml, including cli, core, - exec, linux-sandbox, tui, login, ollama, and mcp. - -• Ran for d in ansi-escape apply-patch arg0 cli common core exec execpolicy - │ file-search linux-sandbox login mcp-client mcp-server mcp-types ollama - │ tui; do echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo; - │ … +1 lines - └ --- ansi-escape/Cargo.toml - [package] - … +243 lines - ] } - tracing = { version - -• Explored - └ Read Cargo.toml - -• I’m evaluating how binary size is influenced by various factors. There are - many binaries, such as codex and codex-exec, which include dependencies by - default, pulling in features like Tokio and hyper. For instance, reqwest - includes default features like TLS through rustls. If default features remain - enabled, it inflates the size with many additional components. I notice - that for musl targets, using vendored builds of OpenSSL could significantly - increase binary size. This is quite the intricate web of dependencies! - - Analyzing musl and binary size - - I’m considering the implications of building for musl with static linking. - In this case, OpenSSL might come into play through some crates, but reqwest - defaults to using rustls, although openssl-sys vendored could increase - binary size. It’s interesting to think about whether they’re targeting musl, - especially since it seems they might often be building for Mac and Linux with - dynamic linking instead. - - I also realize there’s a difference between debug and release builds — release - should be smaller due to optimizations, but if they’re defaulting to debug - builds, that could lead to much larger binaries. - - Assessing crate impacts on size - - I need to be precise when analyzing binary sizes from various crates. Some - crates, like ratatui-image, include heavy features such as libjpeg decoders, - which can really bulk things up. Then there are the tree-sitter crates that - contain large static grammar tables, adding significant size. The starlark - crate and tracing-subscriber also contribute to size increases. - - With static linking per binary being standard in Rust, it leads to multiple - copies of the same dependencies unless cdylibs are used. That makes the - compile times longer, but using LTO helps reduce bloat somewhat through - deduplication. - - Evaluating binary size factors - - Having multiple binaries indeed increases the overall size significantly. - I'm considering options like upgrading to "opt-level = 'z'" or using - "strip=debuginfo" for reducing size. It's interesting that while the profile - only sets symbols for stripping, not debuginfo, this could inflate sizes if - retained in release builds. - - The distinction between "symbols" and "debuginfo" is critical; "symbols" - keeps debug information which may not align with the goal of minimizing - size. I’d like to verify with the Rust documentation, but my network access - is restricted. - - Explaining large binary sizes - - I’m analyzing why binaries in this project are so large. The primary reasons - include monolithic static linking, where each binary includes the full - dependency graph without shared libraries. Heavy dependencies such as reqwest, - hyper, and TLS components also contribute significantly to size, along with - image decoders and other libraries. - - Using MUSL targets means vendored OpenSSL can drastically increase the - size due to static linking. Furthermore, if the release profile keeps debug - information due to using "strip = symbols," that can lead to larger binaries - as well. It’s a mix of several factors, including panic strategies and - platform-specific linking behavior. - - Identifying binary size contributors - - I’m considering why binaries are still large even without static linking. The - tracing-subscriber crate potentially adds weight because it includes regex, - though it can sometimes use a lighter version. References from files like - "core/Cargo.toml" show dependencies like reqwest and tokio, while the tui uses - the image crate. - - To present this clearly, I’ll format the final answer with two main sections: - "Main Causes" and "Build-Mode Notes." I can also include brief suggestions for - reducing size, but I want to stay focused on answering the user's question. - -─ Worked for 0s ──────────────────────────────────────────────────────────────── - -• Here’s what’s driving size in this workspace’s binaries. - - Main Causes - - - Static linking style: Each bin (codex, codex-tui, codex-exec, codex-mcp- - server, etc.) statically links its full dependency graph, so common code isn’t - shared at runtime across executables. - - Heavy deps (HTTP/TLS): reqwest brings in Hyper, HTTP/2, compressors, - and a TLS stack (rustls by default; OpenSSL on musl). In core, login, tui, - and ollama you enable reqwest with json/stream, which still pulls a large - transitive set. - - Image/terminal stack: codex-tui includes image (with jpeg), ratatui, - crossterm, and ratatui-image, which together contribute significant code size - (decoders, pixel ops, terminal backends). - - Parsers/VMs: tree-sitter + tree-sitter-bash (in core and apply-patch) and - starlark (in execpolicy) include sizeable parser/VM tables and runtimes. - - Tokio runtime: Broad tokio features (rt-multi-thread, macros, process, - signal) across many crates inflate code size even if only subsets are used - per bin. - - Panic + backtraces: Default panic = unwind and backtrace support keep - unwinding tables and symbols that add weight. - - Per-target OpenSSL (musl): For *-unknown-linux-musl, core enables openssl- - sys with vendored, compiling OpenSSL into the binary—this adds multiple - megabytes per executable. - - Build-Mode Notes - - - Release settings: You use lto = "fat" and codegen-units = 1 (good for size), - but strip = "symbols" keeps debuginfo. Debuginfo is often the largest single - contributor; if you build in release with that setting, binaries can still - be large. - - Debug builds: cargo build (dev profile) includes full debuginfo, no LTO, and - assertions—outputs are much larger than cargo build --release. - - If you want, I can outline targeted trims (e.g., strip = "debuginfo", opt- - level = "z", panic abort, tighter tokio/reqwest features) and estimate impact - per binary. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h1.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h1.snap deleted file mode 100644 index 1e73a237ebc..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h1.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: terminal.backend() ---- -" " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap deleted file mode 100644 index 7a04b0ef196..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: terminal.backend() ---- -" " -" " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap deleted file mode 100644 index 4487d0652e8..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap +++ /dev/null @@ -1,7 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: terminal.backend() ---- -" " -" " -" " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h1.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h1.snap deleted file mode 100644 index 1e73a237ebc..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h1.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: terminal.backend() ---- -" " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap deleted file mode 100644 index 7a04b0ef196..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: terminal.backend() ---- -" " -" " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap deleted file mode 100644 index 4487d0652e8..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap +++ /dev/null @@ -1,7 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: terminal.backend() ---- -" " -" " -" " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap deleted file mode 100644 index c3bdf60bd2c..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap +++ /dev/null @@ -1,17 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: term.backend().vt100().screen().contents() ---- -• I’m going to search the repo for where “Change Approved” is rendered to update - that view. - -• Explored - └ Search Change Approved - Read diff_render.rs - -• Investigating rendering code (0s • esc to interrupt) - - -› Summarize recent commits - - 100% context left diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap deleted file mode 100644 index 1ed73b5fa5c..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: visual ---- -• -- Indented code block (4 spaces) - SELECT * - FROM "users" - WHERE "email" LIKE '%@example.com'; - - ```sh - printf 'fenced within fenced\n' - ``` - - { - // comment allowed in jsonc - "path": "C:\\Program Files\\App", - "regex": "^foo.*(bar)?$" - } diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap deleted file mode 100644 index 6d9aa515b1a..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap +++ /dev/null @@ -1,27 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: term.backend().vt100().screen().contents() ---- -• Working (0s • esc to interrupt) - ↳ Hello, world! 0 - ↳ Hello, world! 1 - ↳ Hello, world! 2 - ↳ Hello, world! 3 - ↳ Hello, world! 4 - ↳ Hello, world! 5 - ↳ Hello, world! 6 - ↳ Hello, world! 7 - ↳ Hello, world! 8 - ↳ Hello, world! 9 - ↳ Hello, world! 10 - ↳ Hello, world! 11 - ↳ Hello, world! 12 - ↳ Hello, world! 13 - ↳ Hello, world! 14 - ↳ Hello, world! 15 - ↳ Hello, world! 16 - - -› Ask Codex to do anything - - 100% context left · ? for shortcuts diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap deleted file mode 100644 index 6062087181d..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: combined ---- -• Here is the result. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__disabled_slash_command_while_task_running_snapshot.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__disabled_slash_command_while_task_running_snapshot.snap deleted file mode 100644 index e8f08a437ac..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__disabled_slash_command_while_task_running_snapshot.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: blob ---- -■ '/model' is disabled while a task is in progress. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_aborted_long.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_aborted_long.snap deleted file mode 100644 index f04e1f078a8..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_aborted_long.snap +++ /dev/null @@ -1,7 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -assertion_line: 495 -expression: lines_to_single_string(&aborted_long) ---- -✗ You canceled the request to run echo - aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa... diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_aborted_multiline.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_aborted_multiline.snap deleted file mode 100644 index d35cb175972..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_aborted_multiline.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: lines_to_single_string(&aborted_multi) ---- -✗ You canceled the request to run echo line1 ... - diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_approved_short.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_approved_short.snap deleted file mode 100644 index 2f0f1412a1f..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_approved_short.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: lines_to_single_string(&decision) ---- -✔ You approved codex to run echo hello world this time diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap deleted file mode 100644 index 1c6a3ef1367..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap +++ /dev/null @@ -1,36 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: "format!(\"{buf:?}\")" ---- -Buffer { - area: Rect { x: 0, y: 0, width: 80, height: 13 }, - content: [ - " ", - " ", - " Would you like to run the following command? ", - " ", - " Reason: this is a test reason such as one that would be produced by the ", - " model ", - " ", - " $ echo hello world ", - " ", - "› 1. Yes, proceed (y) ", - " 2. No, and tell Codex what to do differently (esc) ", - " ", - " Press enter to confirm or esc to cancel ", - ], - styles: [ - x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 2, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, - x: 46, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 10, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: ITALIC, - x: 73, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 2, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: ITALIC, - x: 7, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 9, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, - x: 21, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 48, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 51, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 2, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - ] -} diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step1_start_ls.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step1_start_ls.snap deleted file mode 100644 index 588a9503eb3..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step1_start_ls.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: blob1 ---- -• Exploring - └ List ls -la diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step2_finish_ls.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step2_finish_ls.snap deleted file mode 100644 index 492e8b7708c..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step2_finish_ls.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: blob2 ---- -• Explored - └ List ls -la diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step3_start_cat_foo.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step3_start_cat_foo.snap deleted file mode 100644 index 2ce41709299..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step3_start_cat_foo.snap +++ /dev/null @@ -1,7 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: blob3 ---- -• Exploring - └ List ls -la - Read foo.txt diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step4_finish_cat_foo.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step4_finish_cat_foo.snap deleted file mode 100644 index 9e29785f715..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step4_finish_cat_foo.snap +++ /dev/null @@ -1,7 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: blob4 ---- -• Explored - └ List ls -la - Read foo.txt diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step5_finish_sed_range.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step5_finish_sed_range.snap deleted file mode 100644 index 296b00f905d..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step5_finish_sed_range.snap +++ /dev/null @@ -1,7 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: blob5 ---- -• Explored - └ List ls -la - Read foo.txt diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step6_finish_cat_bar.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step6_finish_cat_bar.snap deleted file mode 100644 index 55fa9791234..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step6_finish_cat_bar.snap +++ /dev/null @@ -1,7 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: blob6 ---- -• Explored - └ List ls -la - Read foo.txt, bar.txt diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_selection_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_selection_popup.snap deleted file mode 100644 index 4a98242027e..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_selection_popup.snap +++ /dev/null @@ -1,11 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: popup ---- - How was this? - -› 1. bug Crash, error message, hang, or broken UI/behavior. - 2. bad result Output was off-target, incorrect, incomplete, or unhelpful. - 3. good result Helpful, correct, high‑quality, or delightful result worth - celebrating. - 4. other Slowness, feature suggestion, UX feedback, or anything else. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap deleted file mode 100644 index cc3d8e37559..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: popup ---- - Upload logs? - - The following files will be sent: - • codex-logs.log - -› 1. Yes Share the current Codex session logs with the team for - troubleshooting. - 2. No - - Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap deleted file mode 100644 index 6062087181d..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: combined ---- -• Here is the result. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__full_access_confirmation_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__full_access_confirmation_popup.snap deleted file mode 100644 index 71dac5f5902..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__full_access_confirmation_popup.snap +++ /dev/null @@ -1,15 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: popup ---- - Enable full access? - When Codex runs with full access, it can edit any file on your computer and - run commands with network, without your approval. Exercise caution when - enabling full access. This significantly increases the risk of data loss, - leaks, or unexpected behavior. - -› 1. Yes, continue anyway Apply full access for this session - 2. Yes, and don't ask again Enable full access and remember this choice - 3. Cancel Go back without enabling full access - - Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupt_exec_marks_failed.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupt_exec_marks_failed.snap deleted file mode 100644 index 59eff20acee..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupt_exec_marks_failed.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: exec_blob ---- -• Ran sleep 1 - └ (no output) diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupted_turn_error_message.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupted_turn_error_message.snap deleted file mode 100644 index 60715e581e0..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupted_turn_error_message.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: last ---- -■ Conversation interrupted - tell the model what to do differently. Something went wrong? Hit `/feedback` to report the issue. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__local_image_attachment_history_snapshot.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__local_image_attachment_history_snapshot.snap deleted file mode 100644 index cf4c6943fd3..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__local_image_attachment_history_snapshot.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: combined ---- -• Viewed Image - └ example.png diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap deleted file mode 100644 index a4a86a41bac..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap +++ /dev/null @@ -1,12 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: popup ---- - Select Reasoning Level for gpt-5.1-codex-max - - 1. Low Fast responses with lighter reasoning - 2. Medium (default) Balances speed and reasoning depth for everyday tasks -› 3. High (current) Greater reasoning depth for complex problems - 4. Extra high Extra high reasoning depth for complex problems - - Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup_extra_high_warning.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup_extra_high_warning.snap deleted file mode 100644 index 3eb172c1017..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup_extra_high_warning.snap +++ /dev/null @@ -1,16 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -assertion_line: 1548 -expression: popup ---- - Select Reasoning Level for gpt-5.1-codex-max - - 1. Low Fast responses with lighter reasoning - 2. Medium (default) Balances speed and reasoning depth for everyday - tasks - 3. High Greater reasoning depth for complex problems -› 4. Extra high (current) Extra high reasoning depth for complex problems - ⚠ Extra high reasoning effort can quickly consume - Plus plan rate limits. - - Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap deleted file mode 100644 index 56a209ef73a..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap +++ /dev/null @@ -1,15 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: popup ---- - Select Model and Effort - Access legacy models by running codex -m or in your config.toml - -› 1. gpt-5.1-codex-max Latest Codex-optimized flagship for deep and fast - reasoning. - 2. gpt-5.1-codex Optimized for codex. - 3. gpt-5.1-codex-mini Optimized for codex. Cheaper, faster, but less - capable. - 4. gpt-5.1 Broad world knowledge with strong general reasoning. - - Press enter to select reasoning effort, or esc to dismiss. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap deleted file mode 100644 index e210d1f0a39..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: popup ---- - Approaching rate limits - Switch to gpt-5.1-codex-mini for lower credit usage? - -› 1. Switch to gpt-5.1-codex-mini Optimized for codex. Cheaper, - faster, but less capable. - 2. Keep current model - 3. Keep current model (never show again) Hide future rate limit reminders - about switching models. - - Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap deleted file mode 100644 index 9fbebfb500f..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap +++ /dev/null @@ -1,12 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -assertion_line: 1577 -expression: terminal.backend() ---- -" " -"• Analyzing (0s • esc to interrupt) " -" " -" " -"› Ask Codex to do anything " -" " -" 100% context left · ? for shortcuts " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap deleted file mode 100644 index 5e6e33dece9..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap +++ /dev/null @@ -1,17 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: terminal.backend() ---- -" " -" " -" Would you like to run the following command? " -" " -" Reason: this is a test reason such as one that would be produced by the model " -" " -" $ echo 'hello world' " -" " -"› 1. Yes, proceed (y) " -" 2. Yes, and don't ask again for commands that start with `echo 'hello world'` (p) " -" 3. No, and tell Codex what to do differently (esc) " -" " -" Press enter to confirm or esc to cancel " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__update_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__update_popup.snap deleted file mode 100644 index 6a49cb253c4..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__update_popup.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: terminal.backend().vt100().screen().contents() ---- - ✨ New version available! Would you like to update? - - Full release notes: https://github.com/openai/codex/releases/latest - - -› 1. Yes, update now - 2. No, not now - 3. Don't remind me - - Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__user_shell_ls_output.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__user_shell_ls_output.snap deleted file mode 100644 index c67cd637d7a..00000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__user_shell_ls_output.snap +++ /dev/null @@ -1,7 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: blob ---- -• You ran ls - └ file1 - file2 diff --git a/codex-rs/tui2/src/chatwidget/tests.rs b/codex-rs/tui2/src/chatwidget/tests.rs deleted file mode 100644 index ea49400e8a9..00000000000 --- a/codex-rs/tui2/src/chatwidget/tests.rs +++ /dev/null @@ -1,4035 +0,0 @@ -//! Exercises `ChatWidget` event handling and rendering invariants. -//! -//! These tests treat the widget as the adapter between `codex_core::protocol::EventMsg` inputs and -//! the TUI output. Many assertions are snapshot-based so that layout regressions and status/header -//! changes show up as stable, reviewable diffs. - -use super::*; -use crate::app_event::AppEvent; -use crate::app_event::ExitMode; -use crate::app_event_sender::AppEventSender; -use crate::bottom_pane::LocalImageAttachment; -use crate::history_cell::UserHistoryCell; -use crate::test_backend::VT100Backend; -use crate::tui::FrameRequester; -use assert_matches::assert_matches; -use codex_common::approval_presets::builtin_approval_presets; -use codex_core::AuthManager; -use codex_core::CodexAuth; -use codex_core::config::Config; -use codex_core::config::ConfigBuilder; -use codex_core::config::Constrained; -use codex_core::features::Feature; -use codex_core::models_manager::manager::ModelsManager; -use codex_core::protocol::AgentMessageDeltaEvent; -use codex_core::protocol::AgentMessageEvent; -use codex_core::protocol::AgentReasoningDeltaEvent; -use codex_core::protocol::AgentReasoningEvent; -use codex_core::protocol::ApplyPatchApprovalRequestEvent; -use codex_core::protocol::BackgroundEventEvent; -use codex_core::protocol::CreditsSnapshot; -use codex_core::protocol::Event; -use codex_core::protocol::EventMsg; -use codex_core::protocol::ExecApprovalRequestEvent; -use codex_core::protocol::ExecCommandBeginEvent; -use codex_core::protocol::ExecCommandEndEvent; -use codex_core::protocol::ExecCommandSource; -use codex_core::protocol::ExecPolicyAmendment; -use codex_core::protocol::ExitedReviewModeEvent; -use codex_core::protocol::FileChange; -use codex_core::protocol::McpStartupCompleteEvent; -use codex_core::protocol::McpStartupStatus; -use codex_core::protocol::McpStartupUpdateEvent; -use codex_core::protocol::Op; -use codex_core::protocol::PatchApplyBeginEvent; -use codex_core::protocol::PatchApplyEndEvent; -use codex_core::protocol::RateLimitWindow; -use codex_core::protocol::ReviewRequest; -use codex_core::protocol::ReviewTarget; -use codex_core::protocol::SessionSource; -use codex_core::protocol::StreamErrorEvent; -use codex_core::protocol::TokenCountEvent; -use codex_core::protocol::TokenUsage; -use codex_core::protocol::TokenUsageInfo; -use codex_core::protocol::TurnCompleteEvent; -use codex_core::protocol::TurnStartedEvent; -use codex_core::protocol::UndoCompletedEvent; -use codex_core::protocol::UndoStartedEvent; -use codex_core::protocol::ViewImageToolCallEvent; -use codex_core::protocol::WarningEvent; -use codex_otel::OtelManager; -use codex_protocol::ThreadId; -use codex_protocol::account::PlanType; -use codex_protocol::config_types::CollaborationMode; -use codex_protocol::config_types::Settings; -use codex_protocol::openai_models::ModelPreset; -use codex_protocol::openai_models::ReasoningEffortPreset; -use codex_protocol::parse_command::ParsedCommand; -use codex_protocol::plan_tool::PlanItemArg; -use codex_protocol::plan_tool::StepStatus; -use codex_protocol::plan_tool::UpdatePlanArgs; -use codex_protocol::protocol::CodexErrorInfo; -use codex_protocol::user_input::TextElement; -use codex_protocol::user_input::UserInput; -use codex_utils_absolute_path::AbsolutePathBuf; -use crossterm::event::KeyCode; -use crossterm::event::KeyEvent; -use crossterm::event::KeyModifiers; -use insta::assert_snapshot; -use pretty_assertions::assert_eq; -#[cfg(target_os = "windows")] -use serial_test::serial; -use std::collections::HashSet; -use std::path::PathBuf; -use tempfile::NamedTempFile; -use tempfile::tempdir; -use tokio::sync::mpsc::error::TryRecvError; -use tokio::sync::mpsc::unbounded_channel; - -#[cfg(target_os = "windows")] -fn set_windows_sandbox_enabled(enabled: bool) { - codex_core::set_windows_sandbox_enabled(enabled); -} - -#[cfg(target_os = "windows")] -fn set_windows_elevated_sandbox_enabled(enabled: bool) { - codex_core::set_windows_elevated_sandbox_enabled(enabled); -} - -async fn test_config() -> Config { - // Use base defaults to avoid depending on host state. - let codex_home = std::env::temp_dir(); - ConfigBuilder::default() - .codex_home(codex_home.clone()) - .build() - .await - .expect("config") -} - -fn snapshot(percent: f64) -> RateLimitSnapshot { - RateLimitSnapshot { - primary: Some(RateLimitWindow { - used_percent: percent, - window_minutes: Some(60), - resets_at: None, - }), - secondary: None, - credits: None, - plan_type: None, - } -} - -#[tokio::test] -async fn resumed_initial_messages_render_history() { - let (mut chat, mut rx, _ops) = make_chatwidget_manual(None).await; - - let conversation_id = ThreadId::new(); - let rollout_file = NamedTempFile::new().unwrap(); - let configured = codex_core::protocol::SessionConfiguredEvent { - session_id: conversation_id, - forked_from_id: None, - model: "test-model".to_string(), - model_provider_id: "test-provider".to_string(), - approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, - cwd: PathBuf::from("/home/user/project"), - reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, - initial_messages: Some(vec![ - EventMsg::UserMessage(UserMessageEvent { - message: "hello from user".to_string(), - images: None, - text_elements: Vec::new(), - local_images: Vec::new(), - }), - EventMsg::AgentMessage(AgentMessageEvent { - message: "assistant reply".to_string(), - }), - ]), - rollout_path: rollout_file.path().to_path_buf(), - }; - - chat.handle_codex_event(Event { - id: "initial".into(), - msg: EventMsg::SessionConfigured(configured), - }); - - let cells = drain_insert_history(&mut rx); - let mut merged_lines = Vec::new(); - for lines in cells { - let text = lines - .iter() - .flat_map(|line| line.spans.iter()) - .map(|span| span.content.clone()) - .collect::(); - merged_lines.push(text); - } - - let text_blob = merged_lines.join("\n"); - assert!( - text_blob.contains("hello from user"), - "expected replayed user message", - ); - assert!( - text_blob.contains("assistant reply"), - "expected replayed agent message", - ); -} - -#[tokio::test] -async fn replayed_user_message_preserves_text_elements_and_local_images() { - let (mut chat, mut rx, _ops) = make_chatwidget_manual(None).await; - - let placeholder = "[Image #1]"; - let message = format!("{placeholder} replayed"); - let text_elements = vec![TextElement::new( - (0..placeholder.len()).into(), - Some(placeholder.to_string()), - )]; - let local_images = vec![PathBuf::from("/tmp/replay.png")]; - - let conversation_id = ThreadId::new(); - let rollout_file = NamedTempFile::new().unwrap(); - let configured = codex_core::protocol::SessionConfiguredEvent { - session_id: conversation_id, - forked_from_id: None, - model: "test-model".to_string(), - model_provider_id: "test-provider".to_string(), - approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, - cwd: PathBuf::from("/home/user/project"), - reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, - initial_messages: Some(vec![EventMsg::UserMessage(UserMessageEvent { - message: message.clone(), - images: None, - text_elements: text_elements.clone(), - local_images: local_images.clone(), - })]), - rollout_path: rollout_file.path().to_path_buf(), - }; - - chat.handle_codex_event(Event { - id: "initial".into(), - msg: EventMsg::SessionConfigured(configured), - }); - - let mut user_cell = None; - while let Ok(ev) = rx.try_recv() { - if let AppEvent::InsertHistoryCell(cell) = ev - && let Some(cell) = cell.as_any().downcast_ref::() - { - user_cell = Some(( - cell.message.clone(), - cell.text_elements.clone(), - cell.local_image_paths.clone(), - )); - break; - } - } - - let (stored_message, stored_elements, stored_images) = - user_cell.expect("expected a replayed user history cell"); - assert_eq!(stored_message, message); - assert_eq!(stored_elements, text_elements); - assert_eq!(stored_images, local_images); -} - -#[tokio::test] -async fn submission_preserves_text_elements_and_local_images() { - let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; - - let conversation_id = ThreadId::new(); - let rollout_file = NamedTempFile::new().unwrap(); - let configured = codex_core::protocol::SessionConfiguredEvent { - session_id: conversation_id, - forked_from_id: None, - model: "test-model".to_string(), - model_provider_id: "test-provider".to_string(), - approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, - cwd: PathBuf::from("/home/user/project"), - reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, - initial_messages: None, - rollout_path: rollout_file.path().to_path_buf(), - }; - chat.handle_codex_event(Event { - id: "initial".into(), - msg: EventMsg::SessionConfigured(configured), - }); - drain_insert_history(&mut rx); - - let placeholder = "[Image #1]"; - let text = format!("{placeholder} submit"); - let text_elements = vec![TextElement::new( - (0..placeholder.len()).into(), - Some(placeholder.to_string()), - )]; - let local_images = vec![PathBuf::from("/tmp/submitted.png")]; - - chat.bottom_pane - .set_composer_text(text.clone(), text_elements.clone(), local_images.clone()); - chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - let items = match next_submit_op(&mut op_rx) { - Op::UserTurn { items, .. } => items, - other => panic!("expected Op::UserTurn, got {other:?}"), - }; - assert_eq!(items.len(), 2); - assert_eq!( - items[0], - UserInput::LocalImage { - path: local_images[0].clone() - } - ); - assert_eq!( - items[1], - UserInput::Text { - text: text.clone(), - text_elements: text_elements.clone(), - } - ); - - let mut user_cell = None; - while let Ok(ev) = rx.try_recv() { - if let AppEvent::InsertHistoryCell(cell) = ev - && let Some(cell) = cell.as_any().downcast_ref::() - { - user_cell = Some(( - cell.message.clone(), - cell.text_elements.clone(), - cell.local_image_paths.clone(), - )); - break; - } - } - - let (stored_message, stored_elements, stored_images) = - user_cell.expect("expected submitted user history cell"); - assert_eq!(stored_message, text); - assert_eq!(stored_elements, text_elements); - assert_eq!(stored_images, local_images); -} - -#[tokio::test] -async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - - let first_placeholder = "[Image #1]"; - let first_text = format!("{first_placeholder} first"); - let first_elements = vec![TextElement::new( - (0..first_placeholder.len()).into(), - Some(first_placeholder.to_string()), - )]; - let first_images = [PathBuf::from("/tmp/first.png")]; - - let second_placeholder = "[Image #1]"; - let second_text = format!("{second_placeholder} second"); - let second_elements = vec![TextElement::new( - (0..second_placeholder.len()).into(), - Some(second_placeholder.to_string()), - )]; - let second_images = [PathBuf::from("/tmp/second.png")]; - - let existing_placeholder = "[Image #1]"; - let existing_text = format!("{existing_placeholder} existing"); - let existing_elements = vec![TextElement::new( - (0..existing_placeholder.len()).into(), - Some(existing_placeholder.to_string()), - )]; - let existing_images = vec![PathBuf::from("/tmp/existing.png")]; - - chat.queued_user_messages.push_back(UserMessage { - text: first_text, - local_images: vec![LocalImageAttachment { - placeholder: first_placeholder.to_string(), - path: first_images[0].clone(), - }], - text_elements: first_elements, - }); - chat.queued_user_messages.push_back(UserMessage { - text: second_text, - local_images: vec![LocalImageAttachment { - placeholder: second_placeholder.to_string(), - path: second_images[0].clone(), - }], - text_elements: second_elements, - }); - chat.refresh_queued_user_messages(); - - chat.bottom_pane - .set_composer_text(existing_text, existing_elements, existing_images.clone()); - - // When interrupted, queued messages are merged into the composer; image placeholders - // must be renumbered to match the combined local image list. - chat.handle_codex_event(Event { - id: "interrupt".into(), - msg: EventMsg::TurnAborted(codex_core::protocol::TurnAbortedEvent { - reason: TurnAbortReason::Interrupted, - }), - }); - - let first = "[Image #1] first".to_string(); - let second = "[Image #2] second".to_string(); - let third = "[Image #3] existing".to_string(); - let expected_text = format!("{first}\n{second}\n{third}"); - assert_eq!(chat.bottom_pane.composer_text(), expected_text); - - let first_start = 0; - let second_start = first.len() + 1; - let third_start = second_start + second.len() + 1; - let expected_elements = vec![ - TextElement::new( - (first_start..first_start + "[Image #1]".len()).into(), - Some("[Image #1]".to_string()), - ), - TextElement::new( - (second_start..second_start + "[Image #2]".len()).into(), - Some("[Image #2]".to_string()), - ), - TextElement::new( - (third_start..third_start + "[Image #3]".len()).into(), - Some("[Image #3]".to_string()), - ), - ]; - assert_eq!(chat.bottom_pane.composer_text_elements(), expected_elements); - assert_eq!( - chat.bottom_pane.composer_local_image_paths(), - vec![ - first_images[0].clone(), - second_images[0].clone(), - existing_images[0].clone(), - ] - ); -} - -#[tokio::test] -async fn remap_placeholders_uses_attachment_labels() { - let placeholder_one = "[Image #1]"; - let placeholder_two = "[Image #2]"; - let text = format!("{placeholder_two} before {placeholder_one}"); - let elements = vec![ - TextElement::new( - (0..placeholder_two.len()).into(), - Some(placeholder_two.to_string()), - ), - TextElement::new( - ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(), - Some(placeholder_one.to_string()), - ), - ]; - - let attachments = vec![ - LocalImageAttachment { - placeholder: placeholder_one.to_string(), - path: PathBuf::from("/tmp/one.png"), - }, - LocalImageAttachment { - placeholder: placeholder_two.to_string(), - path: PathBuf::from("/tmp/two.png"), - }, - ]; - let message = UserMessage { - text, - text_elements: elements, - local_images: attachments, - }; - let mut next_label = 3usize; - let remapped = remap_placeholders_for_message(message, &mut next_label); - - assert_eq!(remapped.text, "[Image #4] before [Image #3]"); - assert_eq!( - remapped.text_elements, - vec![ - TextElement::new( - (0.."[Image #4]".len()).into(), - Some("[Image #4]".to_string()), - ), - TextElement::new( - ("[Image #4] before ".len().."[Image #4] before [Image #3]".len()).into(), - Some("[Image #3]".to_string()), - ), - ] - ); - assert_eq!( - remapped.local_images, - vec![ - LocalImageAttachment { - placeholder: "[Image #3]".to_string(), - path: PathBuf::from("/tmp/one.png"), - }, - LocalImageAttachment { - placeholder: "[Image #4]".to_string(), - path: PathBuf::from("/tmp/two.png"), - }, - ] - ); -} - -#[tokio::test] -async fn remap_placeholders_uses_byte_ranges_when_placeholder_missing() { - let placeholder_one = "[Image #1]"; - let placeholder_two = "[Image #2]"; - let text = format!("{placeholder_two} before {placeholder_one}"); - let elements = vec![ - TextElement::new((0..placeholder_two.len()).into(), None), - TextElement::new( - ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(), - None, - ), - ]; - - let attachments = vec![ - LocalImageAttachment { - placeholder: placeholder_one.to_string(), - path: PathBuf::from("/tmp/one.png"), - }, - LocalImageAttachment { - placeholder: placeholder_two.to_string(), - path: PathBuf::from("/tmp/two.png"), - }, - ]; - let message = UserMessage { - text, - text_elements: elements, - local_images: attachments, - }; - let mut next_label = 3usize; - let remapped = remap_placeholders_for_message(message, &mut next_label); - - assert_eq!(remapped.text, "[Image #4] before [Image #3]"); - assert_eq!( - remapped.text_elements, - vec![ - TextElement::new( - (0.."[Image #4]".len()).into(), - Some("[Image #4]".to_string()), - ), - TextElement::new( - ("[Image #4] before ".len().."[Image #4] before [Image #3]".len()).into(), - Some("[Image #3]".to_string()), - ), - ] - ); - assert_eq!( - remapped.local_images, - vec![ - LocalImageAttachment { - placeholder: "[Image #3]".to_string(), - path: PathBuf::from("/tmp/one.png"), - }, - LocalImageAttachment { - placeholder: "[Image #4]".to_string(), - path: PathBuf::from("/tmp/two.png"), - }, - ] - ); -} - -/// Entering review mode uses the hint provided by the review request. -#[tokio::test] -async fn entered_review_mode_uses_request_hint() { - let (mut chat, mut rx, _ops) = make_chatwidget_manual(None).await; - - chat.handle_codex_event(Event { - id: "review-start".into(), - msg: EventMsg::EnteredReviewMode(ReviewRequest { - target: ReviewTarget::BaseBranch { - branch: "feature".to_string(), - }, - user_facing_hint: Some("feature branch".to_string()), - }), - }); - - let cells = drain_insert_history(&mut rx); - let banner = lines_to_single_string(cells.last().expect("review banner")); - assert_eq!(banner, ">> Code review started: feature branch <<\n"); - assert!(chat.is_review_mode); -} - -/// Entering review mode renders the current changes banner when requested. -#[tokio::test] -async fn entered_review_mode_defaults_to_current_changes_banner() { - let (mut chat, mut rx, _ops) = make_chatwidget_manual(None).await; - - chat.handle_codex_event(Event { - id: "review-start".into(), - msg: EventMsg::EnteredReviewMode(ReviewRequest { - target: ReviewTarget::UncommittedChanges, - user_facing_hint: None, - }), - }); - - let cells = drain_insert_history(&mut rx); - let banner = lines_to_single_string(cells.last().expect("review banner")); - assert_eq!(banner, ">> Code review started: current changes <<\n"); - assert!(chat.is_review_mode); -} - -/// Exiting review restores the pre-review context window indicator. -#[tokio::test] -async fn review_restores_context_window_indicator() { - let (mut chat, mut rx, _ops) = make_chatwidget_manual(None).await; - - let context_window = 13_000; - let pre_review_tokens = 12_700; // ~30% remaining after subtracting baseline. - let review_tokens = 12_030; // ~97% remaining after subtracting baseline. - - chat.handle_codex_event(Event { - id: "token-before".into(), - msg: EventMsg::TokenCount(TokenCountEvent { - info: Some(make_token_info(pre_review_tokens, context_window)), - rate_limits: None, - }), - }); - assert_eq!(chat.bottom_pane.context_window_percent(), Some(30)); - - chat.handle_codex_event(Event { - id: "review-start".into(), - msg: EventMsg::EnteredReviewMode(ReviewRequest { - target: ReviewTarget::BaseBranch { - branch: "feature".to_string(), - }, - user_facing_hint: Some("feature branch".to_string()), - }), - }); - - chat.handle_codex_event(Event { - id: "token-review".into(), - msg: EventMsg::TokenCount(TokenCountEvent { - info: Some(make_token_info(review_tokens, context_window)), - rate_limits: None, - }), - }); - assert_eq!(chat.bottom_pane.context_window_percent(), Some(97)); - - chat.handle_codex_event(Event { - id: "review-end".into(), - msg: EventMsg::ExitedReviewMode(ExitedReviewModeEvent { - review_output: None, - }), - }); - let _ = drain_insert_history(&mut rx); - - assert_eq!(chat.bottom_pane.context_window_percent(), Some(30)); - assert!(!chat.is_review_mode); -} - -/// Receiving a TokenCount event without usage clears the context indicator. -#[tokio::test] -async fn token_count_none_resets_context_indicator() { - let (mut chat, _rx, _ops) = make_chatwidget_manual(None).await; - - let context_window = 13_000; - let pre_compact_tokens = 12_700; - - chat.handle_codex_event(Event { - id: "token-before".into(), - msg: EventMsg::TokenCount(TokenCountEvent { - info: Some(make_token_info(pre_compact_tokens, context_window)), - rate_limits: None, - }), - }); - assert_eq!(chat.bottom_pane.context_window_percent(), Some(30)); - - chat.handle_codex_event(Event { - id: "token-cleared".into(), - msg: EventMsg::TokenCount(TokenCountEvent { - info: None, - rate_limits: None, - }), - }); - assert_eq!(chat.bottom_pane.context_window_percent(), None); -} - -#[tokio::test] -async fn context_indicator_shows_used_tokens_when_window_unknown() { - let (mut chat, _rx, _ops) = make_chatwidget_manual(Some("unknown-model")).await; - - chat.config.model_context_window = None; - let auto_compact_limit = 200_000; - chat.config.model_auto_compact_token_limit = Some(auto_compact_limit); - - // No model window, so the indicator should fall back to showing tokens used. - let total_tokens = 106_000; - let token_usage = TokenUsage { - total_tokens, - ..TokenUsage::default() - }; - let token_info = TokenUsageInfo { - total_token_usage: token_usage.clone(), - last_token_usage: token_usage, - model_context_window: None, - }; - - chat.handle_codex_event(Event { - id: "token-usage".into(), - msg: EventMsg::TokenCount(TokenCountEvent { - info: Some(token_info), - rate_limits: None, - }), - }); - - assert_eq!(chat.bottom_pane.context_window_percent(), None); - assert_eq!( - chat.bottom_pane.context_window_used_tokens(), - Some(total_tokens) - ); -} - -#[cfg_attr( - target_os = "macos", - ignore = "system configuration APIs are blocked under macOS seatbelt" -)] -#[tokio::test] -async fn helpers_are_available_and_do_not_panic() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let cfg = test_config().await; - let model = cfg.model.clone(); - let resolved_model = ModelsManager::get_model_offline(cfg.model.as_deref()); - let otel_manager = test_otel_manager(&cfg, resolved_model.as_str()); - let thread_manager = Arc::new(ThreadManager::with_models_provider( - CodexAuth::from_api_key("test"), - cfg.model_provider.clone(), - )); - let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); - let init = ChatWidgetInit { - config: cfg, - frame_requester: FrameRequester::test_dummy(), - app_event_tx: tx, - initial_user_message: None, - enhanced_keys_supported: false, - auth_manager, - models_manager: thread_manager.get_models_manager(), - feedback: codex_feedback::CodexFeedback::new(), - is_first_run: true, - model, - otel_manager, - }; - let mut w = ChatWidget::new(init, thread_manager); - // Basic construction sanity. - let _ = &mut w; -} - -fn test_otel_manager(config: &Config, model: &str) -> OtelManager { - let model_info = ModelsManager::construct_model_info_offline(model, config); - OtelManager::new( - ThreadId::new(), - model, - model_info.slug.as_str(), - None, - None, - None, - false, - "test".to_string(), - SessionSource::Cli, - ) -} - -// --- Helpers for tests that need direct construction and event draining --- -async fn make_chatwidget_manual( - model_override: Option<&str>, -) -> ( - ChatWidget, - tokio::sync::mpsc::UnboundedReceiver, - tokio::sync::mpsc::UnboundedReceiver, -) { - let (tx_raw, rx) = unbounded_channel::(); - let app_event_tx = AppEventSender::new(tx_raw); - let (op_tx, op_rx) = unbounded_channel::(); - let mut cfg = test_config().await; - let resolved_model = model_override - .map(str::to_owned) - .unwrap_or_else(|| ModelsManager::get_model_offline(cfg.model.as_deref())); - if let Some(model) = model_override { - cfg.model = Some(model.to_string()); - } - let otel_manager = test_otel_manager(&cfg, resolved_model.as_str()); - let mut bottom = BottomPane::new(BottomPaneParams { - app_event_tx: app_event_tx.clone(), - frame_requester: FrameRequester::test_dummy(), - has_input_focus: true, - enhanced_keys_supported: false, - placeholder_text: "Ask Codex to do anything".to_string(), - disable_paste_burst: false, - animations_enabled: cfg.animations, - skills: None, - }); - bottom.set_steer_enabled(true); - bottom.set_collaboration_modes_enabled(cfg.features.enabled(Feature::CollaborationModes)); - let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); - let codex_home = cfg.codex_home.clone(); - let models_manager = Arc::new(ModelsManager::new(codex_home, auth_manager.clone())); - let collaboration_modes_enabled = cfg.features.enabled(Feature::CollaborationModes); - let reasoning_effort = None; - let stored_collaboration_mode = if collaboration_modes_enabled { - collaboration_modes::default_mode(models_manager.as_ref()).unwrap_or_else(|| { - CollaborationMode::Custom(Settings { - model: resolved_model.clone(), - reasoning_effort, - developer_instructions: None, - }) - }) - } else { - CollaborationMode::Custom(Settings { - model: resolved_model.clone(), - reasoning_effort, - developer_instructions: None, - }) - }; - let widget = ChatWidget { - app_event_tx, - codex_op_tx: op_tx, - bottom_pane: bottom, - active_cell: None, - active_cell_revision: 0, - config: cfg, - stored_collaboration_mode, - auth_manager, - models_manager, - otel_manager, - session_header: SessionHeader::new(resolved_model), - initial_user_message: None, - token_info: None, - rate_limit_snapshot: None, - plan_type: None, - rate_limit_warnings: RateLimitWarningState::default(), - rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), - rate_limit_poller: None, - stream_controller: None, - running_commands: HashMap::new(), - suppressed_exec_calls: HashSet::new(), - last_unified_wait: None, - task_complete_pending: false, - agent_turn_running: false, - mcp_startup_status: None, - interrupts: InterruptManager::new(), - reasoning_buffer: String::new(), - full_reasoning_buffer: String::new(), - current_status_header: String::from("Working"), - retry_status_header: None, - conversation_id: None, - forked_from: None, - frame_requester: FrameRequester::test_dummy(), - show_welcome_banner: true, - queued_user_messages: VecDeque::new(), - suppress_session_configured_redraw: false, - pending_notification: None, - quit_shortcut_expires_at: None, - quit_shortcut_key: None, - is_review_mode: false, - pre_review_token_info: None, - needs_final_message_separator: false, - had_work_activity: false, - last_separator_elapsed_secs: None, - last_rendered_width: std::cell::Cell::new(None), - feedback: codex_feedback::CodexFeedback::new(), - current_rollout_path: None, - }; - (widget, rx, op_rx) -} - -// ChatWidget may emit other `Op`s (e.g. history/logging updates) on the same channel; this helper -// filters until we see a submission op. -fn next_submit_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver) -> Op { - loop { - match op_rx.try_recv() { - Ok(op @ Op::UserTurn { .. }) => return op, - Ok(_) => continue, - Err(TryRecvError::Empty) => panic!("expected a submit op but queue was empty"), - Err(TryRecvError::Disconnected) => panic!("expected submit op but channel closed"), - } - } -} - -fn set_chatgpt_auth(chat: &mut ChatWidget) { - chat.auth_manager = - AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); - chat.models_manager = Arc::new(ModelsManager::new( - chat.config.codex_home.clone(), - chat.auth_manager.clone(), - )); -} - -#[tokio::test] -async fn worked_elapsed_from_resets_when_timer_restarts() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - assert_eq!(chat.worked_elapsed_from(5), 5); - assert_eq!(chat.worked_elapsed_from(9), 4); - // Simulate status timer resetting (e.g., status indicator recreated for a new task). - assert_eq!(chat.worked_elapsed_from(3), 3); - assert_eq!(chat.worked_elapsed_from(7), 4); -} - -pub(crate) async fn make_chatwidget_manual_with_sender() -> ( - ChatWidget, - AppEventSender, - tokio::sync::mpsc::UnboundedReceiver, - tokio::sync::mpsc::UnboundedReceiver, -) { - let (widget, rx, op_rx) = make_chatwidget_manual(None).await; - let app_event_tx = widget.app_event_tx.clone(); - (widget, app_event_tx, rx, op_rx) -} - -fn drain_insert_history( - rx: &mut tokio::sync::mpsc::UnboundedReceiver, -) -> Vec>> { - let mut out = Vec::new(); - while let Ok(ev) = rx.try_recv() { - if let AppEvent::InsertHistoryCell(cell) = ev { - let mut lines = cell.display_lines(80); - if !cell.is_stream_continuation() && !out.is_empty() && !lines.is_empty() { - lines.insert(0, "".into()); - } - out.push(lines) - } - } - out -} - -fn lines_to_single_string(lines: &[ratatui::text::Line<'static>]) -> String { - let mut s = String::new(); - for line in lines { - for span in &line.spans { - s.push_str(&span.content); - } - s.push('\n'); - } - s -} - -fn make_token_info(total_tokens: i64, context_window: i64) -> TokenUsageInfo { - fn usage(total_tokens: i64) -> TokenUsage { - TokenUsage { - total_tokens, - ..TokenUsage::default() - } - } - - TokenUsageInfo { - total_token_usage: usage(total_tokens), - last_token_usage: usage(total_tokens), - model_context_window: Some(context_window), - } -} - -#[tokio::test] -async fn rate_limit_warnings_emit_thresholds() { - let mut state = RateLimitWarningState::default(); - let mut warnings: Vec = Vec::new(); - - warnings.extend(state.take_warnings(Some(10.0), Some(10079), Some(55.0), Some(299))); - warnings.extend(state.take_warnings(Some(55.0), Some(10081), Some(10.0), Some(299))); - warnings.extend(state.take_warnings(Some(10.0), Some(10081), Some(80.0), Some(299))); - warnings.extend(state.take_warnings(Some(80.0), Some(10081), Some(10.0), Some(299))); - warnings.extend(state.take_warnings(Some(10.0), Some(10081), Some(95.0), Some(299))); - warnings.extend(state.take_warnings(Some(95.0), Some(10079), Some(10.0), Some(299))); - - assert_eq!( - warnings, - vec![ - String::from( - "Heads up, you have less than 25% of your 5h limit left. Run /status for a breakdown." - ), - String::from( - "Heads up, you have less than 25% of your weekly limit left. Run /status for a breakdown.", - ), - String::from( - "Heads up, you have less than 5% of your 5h limit left. Run /status for a breakdown." - ), - String::from( - "Heads up, you have less than 5% of your weekly limit left. Run /status for a breakdown.", - ), - ], - "expected one warning per limit for the highest crossed threshold" - ); -} - -#[tokio::test] -async fn test_rate_limit_warnings_monthly() { - let mut state = RateLimitWarningState::default(); - let mut warnings: Vec = Vec::new(); - - warnings.extend(state.take_warnings(Some(75.0), Some(43199), None, None)); - assert_eq!( - warnings, - vec![String::from( - "Heads up, you have less than 25% of your monthly limit left. Run /status for a breakdown.", - ),], - "expected one warning per limit for the highest crossed threshold" - ); -} - -#[tokio::test] -async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - - chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { - primary: None, - secondary: None, - credits: Some(CreditsSnapshot { - has_credits: true, - unlimited: false, - balance: Some("17.5".to_string()), - }), - plan_type: None, - })); - let initial_balance = chat - .rate_limit_snapshot - .as_ref() - .and_then(|snapshot| snapshot.credits.as_ref()) - .and_then(|credits| credits.balance.as_deref()); - assert_eq!(initial_balance, Some("17.5")); - - chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { - primary: Some(RateLimitWindow { - used_percent: 80.0, - window_minutes: Some(60), - resets_at: Some(123), - }), - secondary: None, - credits: None, - plan_type: None, - })); - - let display = chat - .rate_limit_snapshot - .as_ref() - .expect("rate limits should be cached"); - let credits = display - .credits - .as_ref() - .expect("credits should persist when headers omit them"); - - assert_eq!(credits.balance.as_deref(), Some("17.5")); - assert!(!credits.unlimited); - assert_eq!( - display.primary.as_ref().map(|window| window.used_percent), - Some(80.0) - ); -} - -#[tokio::test] -async fn rate_limit_snapshot_updates_and_retains_plan_type() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - - chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { - primary: Some(RateLimitWindow { - used_percent: 10.0, - window_minutes: Some(60), - resets_at: None, - }), - secondary: Some(RateLimitWindow { - used_percent: 5.0, - window_minutes: Some(300), - resets_at: None, - }), - credits: None, - plan_type: Some(PlanType::Plus), - })); - assert_eq!(chat.plan_type, Some(PlanType::Plus)); - - chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { - primary: Some(RateLimitWindow { - used_percent: 25.0, - window_minutes: Some(30), - resets_at: Some(123), - }), - secondary: Some(RateLimitWindow { - used_percent: 15.0, - window_minutes: Some(300), - resets_at: Some(234), - }), - credits: None, - plan_type: Some(PlanType::Pro), - })); - assert_eq!(chat.plan_type, Some(PlanType::Pro)); - - chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { - primary: Some(RateLimitWindow { - used_percent: 30.0, - window_minutes: Some(60), - resets_at: Some(456), - }), - secondary: Some(RateLimitWindow { - used_percent: 18.0, - window_minutes: Some(300), - resets_at: Some(567), - }), - credits: None, - plan_type: None, - })); - assert_eq!(chat.plan_type, Some(PlanType::Pro)); -} - -#[tokio::test] -async fn rate_limit_switch_prompt_skips_when_on_lower_cost_model() { - let (mut chat, _, _) = make_chatwidget_manual(Some(NUDGE_MODEL_SLUG)).await; - chat.auth_manager = - AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); - - chat.on_rate_limit_snapshot(Some(snapshot(95.0))); - - assert!(matches!( - chat.rate_limit_switch_prompt, - RateLimitSwitchPromptState::Idle - )); -} - -#[tokio::test] -async fn rate_limit_switch_prompt_shows_once_per_session() { - let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); - let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")).await; - chat.auth_manager = AuthManager::from_auth_for_testing(auth); - - chat.on_rate_limit_snapshot(Some(snapshot(90.0))); - assert!( - chat.rate_limit_warnings.primary_index >= 1, - "warnings not emitted" - ); - chat.maybe_show_pending_rate_limit_prompt(); - assert!(matches!( - chat.rate_limit_switch_prompt, - RateLimitSwitchPromptState::Shown - )); - - chat.on_rate_limit_snapshot(Some(snapshot(95.0))); - assert!(matches!( - chat.rate_limit_switch_prompt, - RateLimitSwitchPromptState::Shown - )); -} - -#[tokio::test] -async fn rate_limit_switch_prompt_respects_hidden_notice() { - let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); - let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")).await; - chat.auth_manager = AuthManager::from_auth_for_testing(auth); - chat.config.notices.hide_rate_limit_model_nudge = Some(true); - - chat.on_rate_limit_snapshot(Some(snapshot(95.0))); - - assert!(matches!( - chat.rate_limit_switch_prompt, - RateLimitSwitchPromptState::Idle - )); -} - -#[tokio::test] -async fn rate_limit_switch_prompt_defers_until_task_complete() { - let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); - let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")).await; - chat.auth_manager = AuthManager::from_auth_for_testing(auth); - - chat.bottom_pane.set_task_running(true); - chat.on_rate_limit_snapshot(Some(snapshot(90.0))); - assert!(matches!( - chat.rate_limit_switch_prompt, - RateLimitSwitchPromptState::Pending - )); - - chat.bottom_pane.set_task_running(false); - chat.maybe_show_pending_rate_limit_prompt(); - assert!(matches!( - chat.rate_limit_switch_prompt, - RateLimitSwitchPromptState::Shown - )); -} - -#[tokio::test] -async fn rate_limit_switch_prompt_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await; - chat.auth_manager = - AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); - - chat.on_rate_limit_snapshot(Some(snapshot(92.0))); - chat.maybe_show_pending_rate_limit_prompt(); - - let popup = render_bottom_popup(&chat, 80); - assert_snapshot!("rate_limit_switch_prompt_popup", popup); -} - -// (removed experimental resize snapshot test) - -#[tokio::test] -async fn exec_approval_emits_proposed_command_and_decision_history() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - // Trigger an exec approval request with a short, single-line command - let ev = ExecApprovalRequestEvent { - call_id: "call-short".into(), - turn_id: "turn-short".into(), - command: vec!["bash".into(), "-lc".into(), "echo hello world".into()], - cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), - reason: Some( - "this is a test reason such as one that would be produced by the model".into(), - ), - proposed_execpolicy_amendment: None, - parsed_cmd: vec![], - }; - chat.handle_codex_event(Event { - id: "sub-short".into(), - msg: EventMsg::ExecApprovalRequest(ev), - }); - - let proposed_cells = drain_insert_history(&mut rx); - assert!( - proposed_cells.is_empty(), - "expected approval request to render via modal without emitting history cells" - ); - - // The approval modal should display the command snippet for user confirmation. - let area = Rect::new(0, 0, 80, chat.desired_height(80)); - let mut buf = ratatui::buffer::Buffer::empty(area); - chat.render(area, &mut buf); - assert_snapshot!("exec_approval_modal_exec", format!("{buf:?}")); - - // Approve via keyboard and verify a concise decision history line is added - chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); - let decision = drain_insert_history(&mut rx) - .pop() - .expect("expected decision cell in history"); - assert_snapshot!( - "exec_approval_history_decision_approved_short", - lines_to_single_string(&decision) - ); -} - -#[tokio::test] -async fn exec_approval_decision_truncates_multiline_and_long_commands() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - // Multiline command: modal should show full command, history records decision only - let ev_multi = ExecApprovalRequestEvent { - call_id: "call-multi".into(), - turn_id: "turn-multi".into(), - command: vec!["bash".into(), "-lc".into(), "echo line1\necho line2".into()], - cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), - reason: Some( - "this is a test reason such as one that would be produced by the model".into(), - ), - proposed_execpolicy_amendment: None, - parsed_cmd: vec![], - }; - chat.handle_codex_event(Event { - id: "sub-multi".into(), - msg: EventMsg::ExecApprovalRequest(ev_multi), - }); - let proposed_multi = drain_insert_history(&mut rx); - assert!( - proposed_multi.is_empty(), - "expected multiline approval request to render via modal without emitting history cells" - ); - - let area = Rect::new(0, 0, 80, chat.desired_height(80)); - let mut buf = ratatui::buffer::Buffer::empty(area); - chat.render(area, &mut buf); - let mut saw_first_line = false; - for y in 0..area.height { - let mut row = String::new(); - for x in 0..area.width { - row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); - } - if row.contains("echo line1") { - saw_first_line = true; - break; - } - } - assert!( - saw_first_line, - "expected modal to show first line of multiline snippet" - ); - - // Deny via keyboard; decision snippet should be single-line and elided with " ..." - chat.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); - let aborted_multi = drain_insert_history(&mut rx) - .pop() - .expect("expected aborted decision cell (multiline)"); - assert_snapshot!( - "exec_approval_history_decision_aborted_multiline", - lines_to_single_string(&aborted_multi) - ); - - // Very long single-line command: decision snippet should be truncated <= 80 chars with trailing ... - let long = format!("echo {}", "a".repeat(200)); - let ev_long = ExecApprovalRequestEvent { - call_id: "call-long".into(), - turn_id: "turn-long".into(), - command: vec!["bash".into(), "-lc".into(), long], - cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), - reason: None, - proposed_execpolicy_amendment: None, - parsed_cmd: vec![], - }; - chat.handle_codex_event(Event { - id: "sub-long".into(), - msg: EventMsg::ExecApprovalRequest(ev_long), - }); - let proposed_long = drain_insert_history(&mut rx); - assert!( - proposed_long.is_empty(), - "expected long approval request to avoid emitting history cells before decision" - ); - chat.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); - let aborted_long = drain_insert_history(&mut rx) - .pop() - .expect("expected aborted decision cell (long)"); - assert_snapshot!( - "exec_approval_history_decision_aborted_long", - lines_to_single_string(&aborted_long) - ); -} - -// --- Small helpers to tersely drive exec begin/end and snapshot active cell --- -fn begin_exec_with_source( - chat: &mut ChatWidget, - call_id: &str, - raw_cmd: &str, - source: ExecCommandSource, -) -> ExecCommandBeginEvent { - // Build the full command vec and parse it using core's parser, - // then convert to protocol variants for the event payload. - let command = vec!["bash".to_string(), "-lc".to_string(), raw_cmd.to_string()]; - let parsed_cmd: Vec = codex_core::parse_command::parse_command(&command); - let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - let interaction_input = None; - let event = ExecCommandBeginEvent { - call_id: call_id.to_string(), - process_id: None, - turn_id: "turn-1".to_string(), - command, - cwd, - parsed_cmd, - source, - interaction_input, - }; - chat.handle_codex_event(Event { - id: call_id.to_string(), - msg: EventMsg::ExecCommandBegin(event.clone()), - }); - event -} - -fn begin_exec(chat: &mut ChatWidget, call_id: &str, raw_cmd: &str) -> ExecCommandBeginEvent { - begin_exec_with_source(chat, call_id, raw_cmd, ExecCommandSource::Agent) -} - -fn end_exec( - chat: &mut ChatWidget, - begin_event: ExecCommandBeginEvent, - stdout: &str, - stderr: &str, - exit_code: i32, -) { - let aggregated = if stderr.is_empty() { - stdout.to_string() - } else { - format!("{stdout}{stderr}") - }; - let ExecCommandBeginEvent { - call_id, - turn_id, - command, - cwd, - parsed_cmd, - source, - interaction_input, - process_id, - } = begin_event; - chat.handle_codex_event(Event { - id: call_id.clone(), - msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent { - call_id, - process_id, - turn_id, - command, - cwd, - parsed_cmd, - source, - interaction_input, - stdout: stdout.to_string(), - stderr: stderr.to_string(), - aggregated_output: aggregated.clone(), - exit_code, - duration: std::time::Duration::from_millis(5), - formatted_output: aggregated, - }), - }); -} - -fn active_blob(chat: &ChatWidget) -> String { - let lines = chat - .active_cell - .as_ref() - .expect("active cell present") - .display_lines(80); - lines_to_single_string(&lines) -} - -fn get_available_model(chat: &ChatWidget, model: &str) -> ModelPreset { - let models = chat - .models_manager - .try_list_models(&chat.config) - .expect("models lock available"); - models - .iter() - .find(|&preset| preset.model == model) - .cloned() - .unwrap_or_else(|| panic!("{model} preset not found")) -} - -#[tokio::test] -async fn empty_enter_during_task_does_not_queue() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - - // Simulate running task so submissions would normally be queued. - chat.bottom_pane.set_task_running(true); - - // Press Enter with an empty composer. - chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - // Ensure nothing was queued. - assert!(chat.queued_user_messages.is_empty()); -} - -#[tokio::test] -async fn alt_up_edits_most_recent_queued_message() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - - // Simulate a running task so messages would normally be queued. - chat.bottom_pane.set_task_running(true); - - // Seed two queued messages. - chat.queued_user_messages - .push_back(UserMessage::from("first queued".to_string())); - chat.queued_user_messages - .push_back(UserMessage::from("second queued".to_string())); - chat.refresh_queued_user_messages(); - - // Press Alt+Up to edit the most recent (last) queued message. - chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT)); - - // Composer should now contain the last queued message. - assert_eq!( - chat.bottom_pane.composer_text(), - "second queued".to_string() - ); - // And the queue should now contain only the remaining (older) item. - assert_eq!(chat.queued_user_messages.len(), 1); - assert_eq!( - chat.queued_user_messages.front().unwrap().text, - "first queued" - ); -} - -/// Pressing Up to recall the most recent history entry and immediately queuing -/// it while a task is running should always enqueue the same text, even when it -/// is queued repeatedly. -#[tokio::test] -async fn enqueueing_history_prompt_multiple_times_is_stable() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - chat.conversation_id = Some(ThreadId::new()); - assert!(!chat.bottom_pane.is_task_running()); - - // Submit an initial prompt to seed history. - chat.bottom_pane - .set_composer_text("repeat me".to_string(), Vec::new(), Vec::new()); - chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - // Simulate an active task so further submissions are queued. - chat.bottom_pane.set_task_running(true); - assert!(chat.bottom_pane.is_task_running()); - - for _ in 0..3 { - // Recall the prompt from history and ensure it is what we expect. - chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); - assert_eq!(chat.bottom_pane.composer_text(), "repeat me"); - - // Queue the prompt while the task is running. - chat.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - } - - assert_eq!(chat.queued_user_messages.len(), 3); - for message in chat.queued_user_messages.iter() { - assert_eq!(message.text, "repeat me"); - } -} - -#[tokio::test] -async fn streaming_final_answer_keeps_task_running_state() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; - chat.conversation_id = Some(ThreadId::new()); - - chat.on_task_started(); - assert!(chat.bottom_pane.is_task_running()); - chat.on_agent_message_delta("Final answer line\n".to_string()); - chat.on_commit_tick(); - - assert!(chat.bottom_pane.is_task_running()); - assert!(chat.bottom_pane.status_widget().is_none()); - - chat.bottom_pane - .set_composer_text("queued submission".to_string(), Vec::new(), Vec::new()); - chat.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - - assert_eq!(chat.queued_user_messages.len(), 1); - assert_eq!( - chat.queued_user_messages.front().unwrap().text, - "queued submission" - ); - assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); - - chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); - match op_rx.try_recv() { - Ok(Op::Interrupt) => {} - other => panic!("expected Op::Interrupt, got {other:?}"), - } - assert!(!chat.bottom_pane.quit_shortcut_hint_visible()); -} - -#[tokio::test] -async fn ctrl_c_shutdown_works_with_caps_lock() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - chat.handle_key_event(KeyEvent::new(KeyCode::Char('C'), KeyModifiers::CONTROL)); - - assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst))); -} - -#[tokio::test] -async fn ctrl_d_quits_without_prompt() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - chat.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)); - assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst))); -} - -#[tokio::test] -async fn ctrl_d_with_modal_open_does_not_quit() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - chat.open_approvals_popup(); - chat.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)); - - assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); -} - -#[tokio::test] -async fn ctrl_c_cleared_prompt_is_recoverable_via_history() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; - - chat.bottom_pane.insert_str("draft message "); - chat.bottom_pane - .attach_image(PathBuf::from("/tmp/preview.png")); - let placeholder = "[Image #1]"; - assert!( - chat.bottom_pane.composer_text().ends_with(placeholder), - "expected placeholder {placeholder:?} in composer text" - ); - - chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); - assert!(chat.bottom_pane.composer_text().is_empty()); - assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); - assert!(!chat.bottom_pane.quit_shortcut_hint_visible()); - - chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); - let restored_text = chat.bottom_pane.composer_text(); - assert!( - restored_text.ends_with(placeholder), - "expected placeholder {placeholder:?} after history recall" - ); - assert!(restored_text.starts_with("draft message ")); - assert!(!chat.bottom_pane.quit_shortcut_hint_visible()); - - let images = chat.bottom_pane.take_recent_submission_images(); - assert!( - images.is_empty(), - "attachments are not preserved in history recall" - ); -} - -#[tokio::test] -async fn exec_history_cell_shows_working_then_completed() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - // Begin command - let begin = begin_exec(&mut chat, "call-1", "echo done"); - - let cells = drain_insert_history(&mut rx); - assert_eq!(cells.len(), 0, "no exec cell should have been flushed yet"); - - // End command successfully - end_exec(&mut chat, begin, "done", "", 0); - - let cells = drain_insert_history(&mut rx); - // Exec end now finalizes and flushes the exec cell immediately. - assert_eq!(cells.len(), 1, "expected finalized exec cell to flush"); - // Inspect the flushed exec cell rendering. - let lines = &cells[0]; - let blob = lines_to_single_string(lines); - // New behavior: no glyph markers; ensure command is shown and no panic. - assert!( - blob.contains("• Ran"), - "expected summary header present: {blob:?}" - ); - assert!( - blob.contains("echo done"), - "expected command text to be present: {blob:?}" - ); -} - -#[tokio::test] -async fn exec_history_cell_shows_working_then_failed() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - // Begin command - let begin = begin_exec(&mut chat, "call-2", "false"); - let cells = drain_insert_history(&mut rx); - assert_eq!(cells.len(), 0, "no exec cell should have been flushed yet"); - - // End command with failure - end_exec(&mut chat, begin, "", "Bloop", 2); - - let cells = drain_insert_history(&mut rx); - // Exec end with failure should also flush immediately. - assert_eq!(cells.len(), 1, "expected finalized exec cell to flush"); - let lines = &cells[0]; - let blob = lines_to_single_string(lines); - assert!( - blob.contains("• Ran false"), - "expected command and header text present: {blob:?}" - ); - assert!(blob.to_lowercase().contains("bloop"), "expected error text"); -} - -#[tokio::test] -async fn exec_end_without_begin_uses_event_command() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - let command = vec![ - "bash".to_string(), - "-lc".to_string(), - "echo orphaned".to_string(), - ]; - let parsed_cmd = codex_core::parse_command::parse_command(&command); - let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - chat.handle_codex_event(Event { - id: "call-orphan".to_string(), - msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent { - call_id: "call-orphan".to_string(), - process_id: None, - turn_id: "turn-1".to_string(), - command, - cwd, - parsed_cmd, - source: ExecCommandSource::Agent, - interaction_input: None, - stdout: "done".to_string(), - stderr: String::new(), - aggregated_output: "done".to_string(), - exit_code: 0, - duration: std::time::Duration::from_millis(5), - formatted_output: "done".to_string(), - }), - }); - - let cells = drain_insert_history(&mut rx); - assert_eq!(cells.len(), 1, "expected finalized exec cell to flush"); - let blob = lines_to_single_string(&cells[0]); - assert!( - blob.contains("• Ran echo orphaned"), - "expected command text to come from event: {blob:?}" - ); - assert!( - !blob.contains("call-orphan"), - "call id should not be rendered when event has the command: {blob:?}" - ); -} - -#[tokio::test] -async fn exec_history_shows_unified_exec_startup_commands() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - let begin = begin_exec_with_source( - &mut chat, - "call-startup", - "echo unified exec startup", - ExecCommandSource::UnifiedExecStartup, - ); - assert!( - drain_insert_history(&mut rx).is_empty(), - "exec begin should not flush until completion" - ); - - end_exec(&mut chat, begin, "echo unified exec startup\n", "", 0); - - let cells = drain_insert_history(&mut rx); - assert_eq!(cells.len(), 1, "expected finalized exec cell to flush"); - let blob = lines_to_single_string(&cells[0]); - assert!( - blob.contains("• Ran echo unified exec startup"), - "expected startup command to render: {blob:?}" - ); -} - -/// Selecting the custom prompt option from the review popup sends -/// OpenReviewCustomPrompt to the app event channel. -#[tokio::test] -async fn review_popup_custom_prompt_action_sends_event() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - // Open the preset selection popup - chat.open_review_popup(); - - // Move selection down to the fourth item: "Custom review instructions" - chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); - chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); - chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); - // Activate - chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - // Drain events and ensure we saw the OpenReviewCustomPrompt request - let mut found = false; - while let Ok(ev) = rx.try_recv() { - if let AppEvent::OpenReviewCustomPrompt = ev { - found = true; - break; - } - } - assert!(found, "expected OpenReviewCustomPrompt event to be sent"); -} - -#[tokio::test] -async fn slash_init_skips_when_project_doc_exists() { - let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; - let tempdir = tempdir().unwrap(); - let existing_path = tempdir.path().join(DEFAULT_PROJECT_DOC_FILENAME); - std::fs::write(&existing_path, "existing instructions").unwrap(); - chat.config.cwd = tempdir.path().to_path_buf(); - - chat.dispatch_command(SlashCommand::Init); - - match op_rx.try_recv() { - Err(TryRecvError::Empty) => {} - other => panic!("expected no Codex op to be sent, got {other:?}"), - } - - let cells = drain_insert_history(&mut rx); - assert_eq!(cells.len(), 1, "expected one info message"); - let rendered = lines_to_single_string(&cells[0]); - assert!( - rendered.contains(DEFAULT_PROJECT_DOC_FILENAME), - "info message should mention the existing file: {rendered:?}" - ); - assert!( - rendered.contains("Skipping /init"), - "info message should explain why /init was skipped: {rendered:?}" - ); - assert_eq!( - std::fs::read_to_string(existing_path).unwrap(), - "existing instructions" - ); -} - -#[tokio::test] -async fn collab_mode_shift_tab_cycles_only_when_enabled_and_idle() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - chat.set_feature_enabled(Feature::CollaborationModes, false); - - let initial = chat.stored_collaboration_mode.clone(); - chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); - assert_eq!(chat.stored_collaboration_mode, initial); - - chat.set_feature_enabled(Feature::CollaborationModes, true); - - chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); - assert!(matches!( - chat.stored_collaboration_mode, - CollaborationMode::Execute(_) - )); - - chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); - assert!(matches!( - chat.stored_collaboration_mode, - CollaborationMode::Plan(_) - )); - - chat.on_task_started(); - let before = chat.stored_collaboration_mode.clone(); - chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); - assert_eq!(chat.stored_collaboration_mode, before); -} - -#[tokio::test] -async fn collab_slash_command_opens_picker_and_updates_mode() { - let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; - chat.conversation_id = Some(ThreadId::new()); - chat.set_feature_enabled(Feature::CollaborationModes, true); - - chat.dispatch_command(SlashCommand::Collab); - let popup = render_bottom_popup(&chat, 80); - assert!( - popup.contains("Select Collaboration Mode"), - "expected collaboration picker: {popup}" - ); - - chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); - let selected_mode = match rx.try_recv() { - Ok(AppEvent::UpdateCollaborationMode(mode)) => mode, - other => panic!("expected UpdateCollaborationMode event, got {other:?}"), - }; - chat.set_collaboration_mode(selected_mode); - - chat.bottom_pane - .set_composer_text("hello".to_string(), Vec::new(), Vec::new()); - chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); - match next_submit_op(&mut op_rx) { - Op::UserTurn { - collaboration_mode: Some(CollaborationMode::PairProgramming(_)), - .. - } => {} - other => { - panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}") - } - } - - chat.bottom_pane - .set_composer_text("follow up".to_string(), Vec::new(), Vec::new()); - chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); - match next_submit_op(&mut op_rx) { - Op::UserTurn { - collaboration_mode: Some(CollaborationMode::PairProgramming(_)), - .. - } => {} - other => { - panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}") - } - } -} - -#[tokio::test] -async fn collab_mode_defaults_to_pair_programming_when_enabled() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; - chat.conversation_id = Some(ThreadId::new()); - chat.set_feature_enabled(Feature::CollaborationModes, true); - - chat.bottom_pane - .set_composer_text("hello".to_string(), Vec::new(), Vec::new()); - chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); - match next_submit_op(&mut op_rx) { - Op::UserTurn { - collaboration_mode: Some(CollaborationMode::PairProgramming(_)), - .. - } => {} - other => { - panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}") - } - } -} - -#[tokio::test] -async fn collab_mode_enabling_sets_pair_programming_default() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - chat.set_feature_enabled(Feature::CollaborationModes, true); - assert!(matches!( - chat.stored_collaboration_mode, - CollaborationMode::PairProgramming(_) - )); -} - -#[tokio::test] -async fn slash_quit_requests_exit() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - chat.dispatch_command(SlashCommand::Quit); - - assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst))); -} - -#[tokio::test] -async fn slash_exit_requests_exit() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - chat.dispatch_command(SlashCommand::Exit); - - assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst))); -} - -#[tokio::test] -async fn slash_resume_opens_picker() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - chat.dispatch_command(SlashCommand::Resume); - - assert_matches!(rx.try_recv(), Ok(AppEvent::OpenResumePicker)); -} - -#[tokio::test] -async fn slash_fork_requests_current_fork() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - chat.dispatch_command(SlashCommand::Fork); - - assert_matches!(rx.try_recv(), Ok(AppEvent::ForkCurrentSession)); -} - -#[tokio::test] -async fn slash_rollout_displays_current_path() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - let rollout_path = PathBuf::from("/tmp/codex-test-rollout.jsonl"); - chat.current_rollout_path = Some(rollout_path.clone()); - - chat.dispatch_command(SlashCommand::Rollout); - - let cells = drain_insert_history(&mut rx); - assert_eq!(cells.len(), 1, "expected info message for rollout path"); - let rendered = lines_to_single_string(&cells[0]); - assert!( - rendered.contains(&rollout_path.display().to_string()), - "expected rollout path to be shown: {rendered}" - ); -} - -#[tokio::test] -async fn slash_rollout_handles_missing_path() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - chat.dispatch_command(SlashCommand::Rollout); - - let cells = drain_insert_history(&mut rx); - assert_eq!( - cells.len(), - 1, - "expected info message explaining missing path" - ); - let rendered = lines_to_single_string(&cells[0]); - assert!( - rendered.contains("not available"), - "expected missing rollout path message: {rendered}" - ); -} - -#[tokio::test] -async fn undo_success_events_render_info_messages() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - chat.handle_codex_event(Event { - id: "turn-1".to_string(), - msg: EventMsg::UndoStarted(UndoStartedEvent { - message: Some("Undo requested for the last turn...".to_string()), - }), - }); - assert!( - chat.bottom_pane.status_indicator_visible(), - "status indicator should be visible during undo" - ); - - chat.handle_codex_event(Event { - id: "turn-1".to_string(), - msg: EventMsg::UndoCompleted(UndoCompletedEvent { - success: true, - message: None, - }), - }); - - let cells = drain_insert_history(&mut rx); - assert_eq!(cells.len(), 1, "expected final status only"); - assert!( - !chat.bottom_pane.status_indicator_visible(), - "status indicator should be hidden after successful undo" - ); - - let completed = lines_to_single_string(&cells[0]); - assert!( - completed.contains("Undo completed successfully."), - "expected default success message, got {completed:?}" - ); -} - -#[tokio::test] -async fn undo_failure_events_render_error_message() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - chat.handle_codex_event(Event { - id: "turn-2".to_string(), - msg: EventMsg::UndoStarted(UndoStartedEvent { message: None }), - }); - assert!( - chat.bottom_pane.status_indicator_visible(), - "status indicator should be visible during undo" - ); - - chat.handle_codex_event(Event { - id: "turn-2".to_string(), - msg: EventMsg::UndoCompleted(UndoCompletedEvent { - success: false, - message: Some("Failed to restore workspace state.".to_string()), - }), - }); - - let cells = drain_insert_history(&mut rx); - assert_eq!(cells.len(), 1, "expected final status only"); - assert!( - !chat.bottom_pane.status_indicator_visible(), - "status indicator should be hidden after failed undo" - ); - - let completed = lines_to_single_string(&cells[0]); - assert!( - completed.contains("Failed to restore workspace state."), - "expected failure message, got {completed:?}" - ); -} - -#[tokio::test] -async fn undo_started_hides_interrupt_hint() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - - chat.handle_codex_event(Event { - id: "turn-hint".to_string(), - msg: EventMsg::UndoStarted(UndoStartedEvent { message: None }), - }); - - let status = chat - .bottom_pane - .status_widget() - .expect("status indicator should be active"); - assert!( - !status.interrupt_hint_visible(), - "undo should hide the interrupt hint because the operation cannot be cancelled" - ); -} - -/// The commit picker shows only commit subjects (no timestamps). -#[tokio::test] -async fn review_commit_picker_shows_subjects_without_timestamps() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - - // Open the Review presets parent popup. - chat.open_review_popup(); - - // Show commit picker with synthetic entries. - let entries = vec![ - codex_core::git_info::CommitLogEntry { - sha: "1111111deadbeef".to_string(), - timestamp: 0, - subject: "Add new feature X".to_string(), - }, - codex_core::git_info::CommitLogEntry { - sha: "2222222cafebabe".to_string(), - timestamp: 0, - subject: "Fix bug Y".to_string(), - }, - ]; - super::show_review_commit_picker_with_entries(&mut chat, entries); - - // Render the bottom pane and inspect the lines for subjects and absence of time words. - let width = 72; - let height = chat.desired_height(width); - let area = ratatui::layout::Rect::new(0, 0, width, height); - let mut buf = ratatui::buffer::Buffer::empty(area); - chat.render(area, &mut buf); - - let mut blob = String::new(); - for y in 0..area.height { - for x in 0..area.width { - let s = buf[(x, y)].symbol(); - if s.is_empty() { - blob.push(' '); - } else { - blob.push_str(s); - } - } - blob.push('\n'); - } - - assert!( - blob.contains("Add new feature X"), - "expected subject in output" - ); - assert!(blob.contains("Fix bug Y"), "expected subject in output"); - - // Ensure no relative-time phrasing is present. - let lowered = blob.to_lowercase(); - assert!( - !lowered.contains("ago") - && !lowered.contains(" second") - && !lowered.contains(" minute") - && !lowered.contains(" hour") - && !lowered.contains(" day"), - "expected no relative time in commit picker output: {blob:?}" - ); -} - -/// Submitting the custom prompt view sends Op::Review with the typed prompt -/// and uses the same text for the user-facing hint. -#[tokio::test] -async fn custom_prompt_submit_sends_review_op() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - chat.show_review_custom_prompt(); - // Paste prompt text via ChatWidget handler, then submit - chat.handle_paste(" please audit dependencies ".to_string()); - chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - // Expect AppEvent::CodexOp(Op::Review { .. }) with trimmed prompt - let evt = rx.try_recv().expect("expected one app event"); - match evt { - AppEvent::CodexOp(Op::Review { review_request }) => { - assert_eq!( - review_request, - ReviewRequest { - target: ReviewTarget::Custom { - instructions: "please audit dependencies".to_string(), - }, - user_facing_hint: None, - } - ); - } - other => panic!("unexpected app event: {other:?}"), - } -} - -/// Hitting Enter on an empty custom prompt view does not submit. -#[tokio::test] -async fn custom_prompt_enter_empty_does_not_send() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - chat.show_review_custom_prompt(); - // Enter without any text - chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - // No AppEvent::CodexOp should be sent - assert!(rx.try_recv().is_err(), "no app event should be sent"); -} - -#[tokio::test] -async fn view_image_tool_call_adds_history_cell() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - let image_path = chat.config.cwd.join("example.png"); - - chat.handle_codex_event(Event { - id: "sub-image".into(), - msg: EventMsg::ViewImageToolCall(ViewImageToolCallEvent { - call_id: "call-image".into(), - path: image_path, - }), - }); - - let cells = drain_insert_history(&mut rx); - assert_eq!(cells.len(), 1, "expected a single history cell"); - let combined = lines_to_single_string(&cells[0]); - assert_snapshot!("local_image_attachment_history_snapshot", combined); -} - -// Snapshot test: interrupting a running exec finalizes the active cell with a red ✗ -// marker (replacing the spinner) and flushes it into history. -#[tokio::test] -async fn interrupt_exec_marks_failed_snapshot() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - // Begin a long-running command so we have an active exec cell with a spinner. - begin_exec(&mut chat, "call-int", "sleep 1"); - - // Simulate the task being aborted (as if ESC was pressed), which should - // cause the active exec cell to be finalized as failed and flushed. - chat.handle_codex_event(Event { - id: "call-int".into(), - msg: EventMsg::TurnAborted(codex_core::protocol::TurnAbortedEvent { - reason: TurnAbortReason::Interrupted, - }), - }); - - let cells = drain_insert_history(&mut rx); - assert!( - !cells.is_empty(), - "expected finalized exec cell to be inserted into history" - ); - - // The first inserted cell should be the finalized exec; snapshot its text. - let exec_blob = lines_to_single_string(&cells[0]); - assert_snapshot!("interrupt_exec_marks_failed", exec_blob); -} - -// Snapshot test: after an interrupted turn, a gentle error message is inserted -// suggesting the user to tell the model what to do differently and to use /feedback. -#[tokio::test] -async fn interrupted_turn_error_message_snapshot() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - // Simulate an in-progress task so the widget is in a running state. - chat.handle_codex_event(Event { - id: "task-1".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - model_context_window: None, - }), - }); - - // Abort the turn (like pressing Esc) and drain inserted history. - chat.handle_codex_event(Event { - id: "task-1".into(), - msg: EventMsg::TurnAborted(codex_core::protocol::TurnAbortedEvent { - reason: TurnAbortReason::Interrupted, - }), - }); - - let cells = drain_insert_history(&mut rx); - assert!( - !cells.is_empty(), - "expected error message to be inserted after interruption" - ); - let last = lines_to_single_string(cells.last().unwrap()); - assert_snapshot!("interrupted_turn_error_message", last); -} - -/// Opening custom prompt from the review popup, pressing Esc returns to the -/// parent popup, pressing Esc again dismisses all panels (back to normal mode). -#[tokio::test] -async fn review_custom_prompt_escape_navigates_back_then_dismisses() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - - // Open the Review presets parent popup. - chat.open_review_popup(); - - // Open the custom prompt submenu (child view) directly. - chat.show_review_custom_prompt(); - - // Verify child view is on top. - let header = render_bottom_first_row(&chat, 60); - assert!( - header.contains("Custom review instructions"), - "expected custom prompt view header: {header:?}" - ); - - // Esc once: child view closes, parent (review presets) remains. - chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - let header = render_bottom_first_row(&chat, 60); - assert!( - header.contains("Select a review preset"), - "expected to return to parent review popup: {header:?}" - ); - - // Esc again: parent closes; back to normal composer state. - chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - assert!( - chat.is_normal_backtrack_mode(), - "expected to be back in normal composer mode" - ); -} - -/// Opening base-branch picker from the review popup, pressing Esc returns to the -/// parent popup, pressing Esc again dismisses all panels (back to normal mode). -#[tokio::test] -async fn review_branch_picker_escape_navigates_back_then_dismisses() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - - // Open the Review presets parent popup. - chat.open_review_popup(); - - // Open the branch picker submenu (child view). Using a temp cwd with no git repo is fine. - let cwd = std::env::temp_dir(); - chat.show_review_branch_picker(&cwd).await; - - // Verify child view header. - let header = render_bottom_first_row(&chat, 60); - assert!( - header.contains("Select a base branch"), - "expected branch picker header: {header:?}" - ); - - // Esc once: child view closes, parent remains. - chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - let header = render_bottom_first_row(&chat, 60); - assert!( - header.contains("Select a review preset"), - "expected to return to parent review popup: {header:?}" - ); - - // Esc again: parent closes; back to normal composer state. - chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - assert!( - chat.is_normal_backtrack_mode(), - "expected to be back in normal composer mode" - ); -} - -fn render_bottom_first_row(chat: &ChatWidget, width: u16) -> String { - let height = chat.desired_height(width); - let area = Rect::new(0, 0, width, height); - let mut buf = Buffer::empty(area); - chat.render(area, &mut buf); - for y in 0..area.height { - let mut row = String::new(); - for x in 0..area.width { - let s = buf[(x, y)].symbol(); - if s.is_empty() { - row.push(' '); - } else { - row.push_str(s); - } - } - if !row.trim().is_empty() { - return row; - } - } - String::new() -} - -fn render_bottom_popup(chat: &ChatWidget, width: u16) -> String { - let height = chat.desired_height(width); - let area = Rect::new(0, 0, width, height); - let mut buf = Buffer::empty(area); - chat.render(area, &mut buf); - - let mut lines: Vec = (0..area.height) - .map(|row| { - let mut line = String::new(); - for col in 0..area.width { - let symbol = buf[(area.x + col, area.y + row)].symbol(); - if symbol.is_empty() { - line.push(' '); - } else { - line.push_str(symbol); - } - } - line.trim_end().to_string() - }) - .collect(); - - while lines.first().is_some_and(|line| line.trim().is_empty()) { - lines.remove(0); - } - while lines.last().is_some_and(|line| line.trim().is_empty()) { - lines.pop(); - } - - lines.join("\n") -} - -#[tokio::test] -async fn model_selection_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5-codex")).await; - chat.conversation_id = Some(ThreadId::new()); - chat.open_model_popup(); - - let popup = render_bottom_popup(&chat, 80); - assert_snapshot!("model_selection_popup", popup); -} - -#[tokio::test] -async fn model_picker_hides_show_in_picker_false_models_from_cache() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("test-visible-model")).await; - let preset = |slug: &str, show_in_picker: bool| ModelPreset { - id: slug.to_string(), - model: slug.to_string(), - display_name: slug.to_string(), - description: format!("{slug} description"), - default_reasoning_effort: ReasoningEffortConfig::Medium, - supported_reasoning_efforts: vec![ReasoningEffortPreset { - effort: ReasoningEffortConfig::Medium, - description: "medium".to_string(), - }], - is_default: false, - upgrade: None, - show_in_picker, - supported_in_api: true, - }; - - chat.open_model_popup_with_presets(vec![ - preset("test-visible-model", true), - preset("test-hidden-model", false), - ]); - let popup = render_bottom_popup(&chat, 80); - assert_snapshot!("model_picker_filters_hidden_models", popup); - assert!( - popup.contains("test-visible-model"), - "expected visible model to appear in picker:\n{popup}" - ); - assert!( - !popup.contains("test-hidden-model"), - "expected hidden model to be excluded from picker:\n{popup}" - ); -} - -#[tokio::test] -async fn approvals_selection_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - - chat.config.notices.hide_full_access_warning = None; - chat.open_approvals_popup(); - - let popup = render_bottom_popup(&chat, 80); - #[cfg(target_os = "windows")] - insta::with_settings!({ snapshot_suffix => "windows" }, { - assert_snapshot!("approvals_selection_popup", popup); - }); - #[cfg(not(target_os = "windows"))] - assert_snapshot!("approvals_selection_popup", popup); -} - -#[cfg(target_os = "windows")] -#[tokio::test] -#[serial] -async fn approvals_selection_popup_snapshot_windows_degraded_sandbox() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - - let was_sandbox_enabled = codex_core::get_platform_sandbox().is_some(); - let was_elevated_enabled = codex_core::is_windows_elevated_sandbox_enabled(); - - chat.config.notices.hide_full_access_warning = None; - chat.config.features.enable(Feature::WindowsSandbox); - chat.config - .features - .disable(Feature::WindowsSandboxElevated); - set_windows_sandbox_enabled(true); - set_windows_elevated_sandbox_enabled(false); - - chat.open_approvals_popup(); - - let popup = render_bottom_popup(&chat, 80); - insta::with_settings!({ snapshot_suffix => "windows_degraded" }, { - assert_snapshot!("approvals_selection_popup", popup); - }); - - // Avoid leaking sandbox global state into other tests. - set_windows_sandbox_enabled(was_sandbox_enabled); - set_windows_elevated_sandbox_enabled(was_elevated_enabled); -} - -#[tokio::test] -async fn preset_matching_ignores_extra_writable_roots() { - let preset = builtin_approval_presets() - .into_iter() - .find(|p| p.id == "auto") - .expect("auto preset exists"); - let current_sandbox = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![AbsolutePathBuf::try_from("C:\\extra").unwrap()], - network_access: false, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - }; - - assert!( - ChatWidget::preset_matches_current(AskForApproval::OnRequest, ¤t_sandbox, &preset), - "WorkspaceWrite with extra roots should still match the Agent preset" - ); - assert!( - !ChatWidget::preset_matches_current(AskForApproval::Never, ¤t_sandbox, &preset), - "approval mismatch should prevent matching the preset" - ); -} - -#[tokio::test] -async fn full_access_confirmation_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - - let preset = builtin_approval_presets() - .into_iter() - .find(|preset| preset.id == "full-access") - .expect("full access preset"); - chat.open_full_access_confirmation(preset); - - let popup = render_bottom_popup(&chat, 80); - assert_snapshot!("full_access_confirmation_popup", popup); -} - -#[cfg(target_os = "windows")] -#[tokio::test] -async fn windows_auto_mode_prompt_requests_enabling_sandbox_feature() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - - let preset = builtin_approval_presets() - .into_iter() - .find(|preset| preset.id == "auto") - .expect("auto preset"); - chat.open_windows_sandbox_enable_prompt(preset); - - let popup = render_bottom_popup(&chat, 120); - assert!( - popup.contains("requires elevation"), - "expected auto mode prompt to mention elevation, popup: {popup}" - ); -} - -#[cfg(target_os = "windows")] -#[tokio::test] -async fn startup_prompts_for_windows_sandbox_when_agent_requested() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - - set_windows_sandbox_enabled(false); - chat.config.forced_auto_mode_downgraded_on_windows = true; - - chat.maybe_prompt_windows_sandbox_enable(); - - let popup = render_bottom_popup(&chat, 120); - assert!( - popup.contains("requires elevation"), - "expected startup prompt to explain elevation: {popup}" - ); - assert!( - popup.contains("Set up agent sandbox"), - "expected startup prompt to offer agent sandbox setup: {popup}" - ); - assert!( - popup.contains("Stay in"), - "expected startup prompt to offer staying in current mode: {popup}" - ); - - set_windows_sandbox_enabled(true); -} - -#[tokio::test] -async fn model_reasoning_selection_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; - - set_chatgpt_auth(&mut chat); - chat.set_reasoning_effort(Some(ReasoningEffortConfig::High)); - - let preset = get_available_model(&chat, "gpt-5.1-codex-max"); - chat.open_reasoning_popup(preset); - - let popup = render_bottom_popup(&chat, 80); - assert_snapshot!("model_reasoning_selection_popup", popup); -} - -#[tokio::test] -async fn model_reasoning_selection_popup_extra_high_warning_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; - - set_chatgpt_auth(&mut chat); - chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh)); - - let preset = get_available_model(&chat, "gpt-5.1-codex-max"); - chat.open_reasoning_popup(preset); - - let popup = render_bottom_popup(&chat, 80); - assert_snapshot!("model_reasoning_selection_popup_extra_high_warning", popup); -} - -#[tokio::test] -async fn reasoning_popup_shows_extra_high_with_space() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; - - set_chatgpt_auth(&mut chat); - - let preset = get_available_model(&chat, "gpt-5.1-codex-max"); - chat.open_reasoning_popup(preset); - - let popup = render_bottom_popup(&chat, 120); - assert!( - popup.contains("Extra high"), - "expected popup to include 'Extra high'; popup: {popup}" - ); - assert!( - !popup.contains("Extrahigh"), - "expected popup not to include 'Extrahigh'; popup: {popup}" - ); -} - -#[tokio::test] -async fn single_reasoning_option_skips_selection() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - let single_effort = vec![ReasoningEffortPreset { - effort: ReasoningEffortConfig::High, - description: "Greater reasoning depth for complex or ambiguous problems".to_string(), - }]; - let preset = ModelPreset { - id: "model-with-single-reasoning".to_string(), - model: "model-with-single-reasoning".to_string(), - display_name: "model-with-single-reasoning".to_string(), - description: "".to_string(), - default_reasoning_effort: ReasoningEffortConfig::High, - supported_reasoning_efforts: single_effort, - is_default: false, - upgrade: None, - show_in_picker: true, - supported_in_api: true, - }; - chat.open_reasoning_popup(preset); - - let popup = render_bottom_popup(&chat, 80); - assert!( - !popup.contains("Select Reasoning Level"), - "expected reasoning selection popup to be skipped" - ); - - let mut events = Vec::new(); - while let Ok(ev) = rx.try_recv() { - events.push(ev); - } - - assert!( - events - .iter() - .any(|ev| matches!(ev, AppEvent::UpdateReasoningEffort(Some(effort)) if *effort == ReasoningEffortConfig::High)), - "expected reasoning effort to be applied automatically; events: {events:?}" - ); -} - -#[tokio::test] -async fn feedback_selection_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - - // Open the feedback category selection popup via slash command. - chat.dispatch_command(SlashCommand::Feedback); - - let popup = render_bottom_popup(&chat, 80); - assert_snapshot!("feedback_selection_popup", popup); -} - -#[tokio::test] -async fn feedback_upload_consent_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - - // Open the consent popup directly for a chosen category. - chat.open_feedback_consent(crate::app_event::FeedbackCategory::Bug); - - let popup = render_bottom_popup(&chat, 80); - assert_snapshot!("feedback_upload_consent_popup", popup); -} - -#[tokio::test] -async fn reasoning_popup_escape_returns_to_model_popup() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; - chat.conversation_id = Some(ThreadId::new()); - chat.open_model_popup(); - - let preset = get_available_model(&chat, "gpt-5.1-codex-max"); - chat.open_reasoning_popup(preset); - - let before_escape = render_bottom_popup(&chat, 80); - assert!(before_escape.contains("Select Reasoning Level")); - - chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - - let after_escape = render_bottom_popup(&chat, 80); - assert!(after_escape.contains("Select Model")); - assert!(!after_escape.contains("Select Reasoning Level")); -} - -#[tokio::test] -async fn exec_history_extends_previous_when_consecutive() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - - // 1) Start "ls -la" (List) - let begin_ls = begin_exec(&mut chat, "call-ls", "ls -la"); - assert_snapshot!("exploring_step1_start_ls", active_blob(&chat)); - - // 2) Finish "ls -la" - end_exec(&mut chat, begin_ls, "", "", 0); - assert_snapshot!("exploring_step2_finish_ls", active_blob(&chat)); - - // 3) Start "cat foo.txt" (Read) - let begin_cat_foo = begin_exec(&mut chat, "call-cat-foo", "cat foo.txt"); - assert_snapshot!("exploring_step3_start_cat_foo", active_blob(&chat)); - - // 4) Complete "cat foo.txt" - end_exec(&mut chat, begin_cat_foo, "hello from foo", "", 0); - assert_snapshot!("exploring_step4_finish_cat_foo", active_blob(&chat)); - - // 5) Start & complete "sed -n 100,200p foo.txt" (treated as Read of foo.txt) - let begin_sed_range = begin_exec(&mut chat, "call-sed-range", "sed -n 100,200p foo.txt"); - end_exec(&mut chat, begin_sed_range, "chunk", "", 0); - assert_snapshot!("exploring_step5_finish_sed_range", active_blob(&chat)); - - // 6) Start & complete "cat bar.txt" - let begin_cat_bar = begin_exec(&mut chat, "call-cat-bar", "cat bar.txt"); - end_exec(&mut chat, begin_cat_bar, "hello from bar", "", 0); - assert_snapshot!("exploring_step6_finish_cat_bar", active_blob(&chat)); -} - -#[tokio::test] -async fn user_shell_command_renders_output_not_exploring() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - let begin_ls = begin_exec_with_source( - &mut chat, - "user-shell-ls", - "ls", - ExecCommandSource::UserShell, - ); - end_exec(&mut chat, begin_ls, "file1\nfile2\n", "", 0); - - let cells = drain_insert_history(&mut rx); - assert_eq!( - cells.len(), - 1, - "expected a single history cell for the user command" - ); - let blob = lines_to_single_string(cells.first().unwrap()); - assert_snapshot!("user_shell_ls_output", blob); -} - -#[tokio::test] -async fn disabled_slash_command_while_task_running_snapshot() { - // Build a chat widget and simulate an active task - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - chat.bottom_pane.set_task_running(true); - - // Dispatch a command that is unavailable while a task runs (e.g., /model) - chat.dispatch_command(SlashCommand::Model); - - // Drain history and snapshot the rendered error line(s) - let cells = drain_insert_history(&mut rx); - assert!( - !cells.is_empty(), - "expected an error message history cell to be emitted", - ); - let blob = lines_to_single_string(cells.last().unwrap()); - assert_snapshot!(blob); -} - -// -// Snapshot test: command approval modal -// -// Synthesizes a Codex ExecApprovalRequest event to trigger the approval modal -// and snapshots the visual output using the ratatui TestBackend. -#[tokio::test] -async fn approval_modal_exec_snapshot() { - // Build a chat widget with manual channels to avoid spawning the agent. - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - // Ensure policy allows surfacing approvals explicitly (not strictly required for direct event). - chat.config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); - // Inject an exec approval request to display the approval modal. - let ev = ExecApprovalRequestEvent { - call_id: "call-approve-cmd".into(), - turn_id: "turn-approve-cmd".into(), - command: vec!["bash".into(), "-lc".into(), "echo hello world".into()], - cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), - reason: Some( - "this is a test reason such as one that would be produced by the model".into(), - ), - proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ - "echo".into(), - "hello".into(), - "world".into(), - ])), - parsed_cmd: vec![], - }; - chat.handle_codex_event(Event { - id: "sub-approve".into(), - msg: EventMsg::ExecApprovalRequest(ev), - }); - // Render to a fixed-size test terminal and snapshot. - // Call desired_height first and use that exact height for rendering. - let width = 100; - let height = chat.desired_height(width); - let mut terminal = - crate::custom_terminal::Terminal::with_options(VT100Backend::new(width, height)) - .expect("create terminal"); - let viewport = Rect::new(0, 0, width, height); - terminal.set_viewport_area(viewport); - - terminal - .draw(|f| chat.render(f.area(), f.buffer_mut())) - .expect("draw approval modal"); - assert!( - terminal - .backend() - .vt100() - .screen() - .contents() - .contains("echo hello world") - ); - assert_snapshot!( - "approval_modal_exec", - terminal.backend().vt100().screen().contents() - ); -} - -// Snapshot test: command approval modal without a reason -// Ensures spacing looks correct when no reason text is provided. -#[tokio::test] -async fn approval_modal_exec_without_reason_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - chat.config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); - - let ev = ExecApprovalRequestEvent { - call_id: "call-approve-cmd-noreason".into(), - turn_id: "turn-approve-cmd-noreason".into(), - command: vec!["bash".into(), "-lc".into(), "echo hello world".into()], - cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), - reason: None, - proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ - "echo".into(), - "hello".into(), - "world".into(), - ])), - parsed_cmd: vec![], - }; - chat.handle_codex_event(Event { - id: "sub-approve-noreason".into(), - msg: EventMsg::ExecApprovalRequest(ev), - }); - - let width = 100; - let height = chat.desired_height(width); - let mut terminal = - ratatui::Terminal::new(VT100Backend::new(width, height)).expect("create terminal"); - terminal.set_viewport_area(Rect::new(0, 0, width, height)); - terminal - .draw(|f| chat.render(f.area(), f.buffer_mut())) - .expect("draw approval modal (no reason)"); - assert_snapshot!( - "approval_modal_exec_no_reason", - terminal.backend().vt100().screen().contents() - ); -} - -// Snapshot test: approval modal with a proposed execpolicy prefix that is multi-line; -// we should not offer adding it to execpolicy. -#[tokio::test] -async fn approval_modal_exec_multiline_prefix_hides_execpolicy_option_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - chat.config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); - - let script = "python - <<'PY'\nprint('hello')\nPY".to_string(); - let command = vec!["bash".into(), "-lc".into(), script]; - let ev = ExecApprovalRequestEvent { - call_id: "call-approve-cmd-multiline-trunc".into(), - turn_id: "turn-approve-cmd-multiline-trunc".into(), - command: command.clone(), - cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), - reason: None, - proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)), - parsed_cmd: vec![], - }; - chat.handle_codex_event(Event { - id: "sub-approve-multiline-trunc".into(), - msg: EventMsg::ExecApprovalRequest(ev), - }); - - let width = 100; - let height = chat.desired_height(width); - let mut terminal = - ratatui::Terminal::new(VT100Backend::new(width, height)).expect("create terminal"); - terminal.set_viewport_area(Rect::new(0, 0, width, height)); - terminal - .draw(|f| chat.render(f.area(), f.buffer_mut())) - .expect("draw approval modal (multiline prefix)"); - let contents = terminal.backend().vt100().screen().contents(); - assert!(!contents.contains("don't ask again")); - assert_snapshot!( - "approval_modal_exec_multiline_prefix_no_execpolicy", - contents - ); -} - -// Snapshot test: patch approval modal -#[tokio::test] -async fn approval_modal_patch_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - chat.config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); - - // Build a small changeset and a reason/grant_root to exercise the prompt text. - let mut changes = HashMap::new(); - changes.insert( - PathBuf::from("README.md"), - FileChange::Add { - content: "hello\nworld\n".into(), - }, - ); - let ev = ApplyPatchApprovalRequestEvent { - call_id: "call-approve-patch".into(), - turn_id: "turn-approve-patch".into(), - changes, - reason: Some("The model wants to apply changes".into()), - grant_root: Some(PathBuf::from("/tmp")), - }; - chat.handle_codex_event(Event { - id: "sub-approve-patch".into(), - msg: EventMsg::ApplyPatchApprovalRequest(ev), - }); - - // Render at the widget's desired height and snapshot. - let height = chat.desired_height(80); - let mut terminal = - ratatui::Terminal::new(VT100Backend::new(80, height)).expect("create terminal"); - terminal.set_viewport_area(Rect::new(0, 0, 80, height)); - terminal - .draw(|f| chat.render(f.area(), f.buffer_mut())) - .expect("draw patch approval modal"); - assert_snapshot!( - "approval_modal_patch", - terminal.backend().vt100().screen().contents() - ); -} - -#[tokio::test] -async fn interrupt_restores_queued_messages_into_composer() { - let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; - - // Simulate a running task to enable queuing of user inputs. - chat.bottom_pane.set_task_running(true); - - // Queue two user messages while the task is running. - chat.queued_user_messages - .push_back(UserMessage::from("first queued".to_string())); - chat.queued_user_messages - .push_back(UserMessage::from("second queued".to_string())); - chat.refresh_queued_user_messages(); - - // Deliver a TurnAborted event with Interrupted reason (as if Esc was pressed). - chat.handle_codex_event(Event { - id: "turn-1".into(), - msg: EventMsg::TurnAborted(codex_core::protocol::TurnAbortedEvent { - reason: TurnAbortReason::Interrupted, - }), - }); - - // Composer should now contain the queued messages joined by newlines, in order. - assert_eq!( - chat.bottom_pane.composer_text(), - "first queued\nsecond queued" - ); - - // Queue should be cleared and no new user input should have been auto-submitted. - assert!(chat.queued_user_messages.is_empty()); - assert!( - op_rx.try_recv().is_err(), - "unexpected outbound op after interrupt" - ); - - // Drain rx to avoid unused warnings. - let _ = drain_insert_history(&mut rx); -} - -#[tokio::test] -async fn interrupt_prepends_queued_messages_before_existing_composer_text() { - let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; - - chat.bottom_pane.set_task_running(true); - chat.bottom_pane - .set_composer_text("current draft".to_string(), Vec::new(), Vec::new()); - - chat.queued_user_messages - .push_back(UserMessage::from("first queued".to_string())); - chat.queued_user_messages - .push_back(UserMessage::from("second queued".to_string())); - chat.refresh_queued_user_messages(); - - chat.handle_codex_event(Event { - id: "turn-1".into(), - msg: EventMsg::TurnAborted(codex_core::protocol::TurnAbortedEvent { - reason: TurnAbortReason::Interrupted, - }), - }); - - assert_eq!( - chat.bottom_pane.composer_text(), - "first queued\nsecond queued\ncurrent draft" - ); - assert!(chat.queued_user_messages.is_empty()); - assert!( - op_rx.try_recv().is_err(), - "unexpected outbound op after interrupt" - ); - - let _ = drain_insert_history(&mut rx); -} - -// Snapshot test: ChatWidget at very small heights (idle) -// Ensures overall layout behaves when terminal height is extremely constrained. -#[tokio::test] -async fn ui_snapshots_small_heights_idle() { - use ratatui::Terminal; - use ratatui::backend::TestBackend; - let (chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - for h in [1u16, 2, 3] { - let name = format!("chat_small_idle_h{h}"); - let mut terminal = Terminal::new(TestBackend::new(40, h)).expect("create terminal"); - terminal - .draw(|f| chat.render(f.area(), f.buffer_mut())) - .expect("draw chat idle"); - assert_snapshot!(name, terminal.backend()); - } -} - -// Snapshot test: ChatWidget at very small heights (task running) -// Validates how status + composer are presented within tight space. -#[tokio::test] -async fn ui_snapshots_small_heights_task_running() { - use ratatui::Terminal; - use ratatui::backend::TestBackend; - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - // Activate status line - chat.handle_codex_event(Event { - id: "task-1".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - model_context_window: None, - }), - }); - chat.handle_codex_event(Event { - id: "task-1".into(), - msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { - delta: "**Thinking**".into(), - }), - }); - for h in [1u16, 2, 3] { - let name = format!("chat_small_running_h{h}"); - let mut terminal = Terminal::new(TestBackend::new(40, h)).expect("create terminal"); - terminal - .draw(|f| chat.render(f.area(), f.buffer_mut())) - .expect("draw chat running"); - assert_snapshot!(name, terminal.backend()); - } -} - -// Snapshot test: status widget + approval modal active together -// The modal takes precedence visually; this captures the layout with a running -// task (status indicator active) while an approval request is shown. -#[tokio::test] -async fn status_widget_and_approval_modal_snapshot() { - use codex_core::protocol::ExecApprovalRequestEvent; - - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - // Begin a running task so the status indicator would be active. - chat.handle_codex_event(Event { - id: "task-1".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - model_context_window: None, - }), - }); - // Provide a deterministic header for the status line. - chat.handle_codex_event(Event { - id: "task-1".into(), - msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { - delta: "**Analyzing**".into(), - }), - }); - - // Now show an approval modal (e.g. exec approval). - let ev = ExecApprovalRequestEvent { - call_id: "call-approve-exec".into(), - turn_id: "turn-approve-exec".into(), - command: vec!["echo".into(), "hello world".into()], - cwd: PathBuf::from("/tmp"), - reason: Some( - "this is a test reason such as one that would be produced by the model".into(), - ), - proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ - "echo".into(), - "hello world".into(), - ])), - parsed_cmd: vec![], - }; - chat.handle_codex_event(Event { - id: "sub-approve-exec".into(), - msg: EventMsg::ExecApprovalRequest(ev), - }); - - // Render at the widget's desired height and snapshot. - let width: u16 = 100; - let height = chat.desired_height(width); - let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(width, height)) - .expect("create terminal"); - terminal.set_viewport_area(Rect::new(0, 0, width, height)); - terminal - .draw(|f| chat.render(f.area(), f.buffer_mut())) - .expect("draw status + approval modal"); - assert_snapshot!("status_widget_and_approval_modal", terminal.backend()); -} - -// Snapshot test: status widget active (StatusIndicatorView) -// Ensures the VT100 rendering of the status indicator is stable when active. -#[tokio::test] -async fn status_widget_active_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - // Activate the status indicator by simulating a task start. - chat.handle_codex_event(Event { - id: "task-1".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - model_context_window: None, - }), - }); - // Provide a deterministic header via a bold reasoning chunk. - chat.handle_codex_event(Event { - id: "task-1".into(), - msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { - delta: "**Analyzing**".into(), - }), - }); - // Render and snapshot. - let height = chat.desired_height(80); - let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, height)) - .expect("create terminal"); - terminal - .draw(|f| chat.render(f.area(), f.buffer_mut())) - .expect("draw status widget"); - assert_snapshot!("status_widget_active", terminal.backend()); -} - -#[tokio::test] -async fn mcp_startup_header_booting_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - chat.show_welcome_banner = false; - - chat.handle_codex_event(Event { - id: "mcp-1".into(), - msg: EventMsg::McpStartupUpdate(McpStartupUpdateEvent { - server: "alpha".into(), - status: McpStartupStatus::Starting, - }), - }); - - let height = chat.desired_height(80); - let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, height)) - .expect("create terminal"); - terminal - .draw(|f| chat.render(f.area(), f.buffer_mut())) - .expect("draw chat widget"); - assert_snapshot!("mcp_startup_header_booting", terminal.backend()); -} - -#[tokio::test] -async fn mcp_startup_complete_does_not_clear_running_task() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - - chat.handle_codex_event(Event { - id: "task-1".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - model_context_window: None, - }), - }); - - // The bottom pane has a single "task running" indicator even though MCP startup and an agent - // turn are tracked independently, so a startup completion event must not clear an active turn. - assert!(chat.bottom_pane.is_task_running()); - assert!(chat.bottom_pane.status_indicator_visible()); - - chat.handle_codex_event(Event { - id: "mcp-1".into(), - msg: EventMsg::McpStartupComplete(McpStartupCompleteEvent { - ready: vec!["schaltwerk".into()], - ..Default::default() - }), - }); - - assert!(chat.bottom_pane.is_task_running()); - assert!(chat.bottom_pane.status_indicator_visible()); -} - -#[tokio::test] -async fn background_event_updates_status_header() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - chat.handle_codex_event(Event { - id: "bg-1".into(), - msg: EventMsg::BackgroundEvent(BackgroundEventEvent { - message: "Waiting for `vim`".to_string(), - }), - }); - - assert!(chat.bottom_pane.status_indicator_visible()); - assert_eq!(chat.current_status_header, "Waiting for `vim`"); - assert!(drain_insert_history(&mut rx).is_empty()); -} - -#[tokio::test] -async fn apply_patch_events_emit_history_cells() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - // 1) Approval request -> proposed patch summary cell - let mut changes = HashMap::new(); - changes.insert( - PathBuf::from("foo.txt"), - FileChange::Add { - content: "hello\n".to_string(), - }, - ); - let ev = ApplyPatchApprovalRequestEvent { - call_id: "c1".into(), - turn_id: "turn-c1".into(), - changes, - reason: None, - grant_root: None, - }; - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::ApplyPatchApprovalRequest(ev), - }); - let cells = drain_insert_history(&mut rx); - assert!( - cells.is_empty(), - "expected approval request to surface via modal without emitting history cells" - ); - - let area = Rect::new(0, 0, 80, chat.desired_height(80)); - let mut buf = ratatui::buffer::Buffer::empty(area); - chat.render(area, &mut buf); - let mut saw_summary = false; - for y in 0..area.height { - let mut row = String::new(); - for x in 0..area.width { - row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); - } - if row.contains("foo.txt (+1 -0)") { - saw_summary = true; - break; - } - } - assert!(saw_summary, "expected approval modal to show diff summary"); - - // 2) Begin apply -> per-file apply block cell (no global header) - let mut changes2 = HashMap::new(); - changes2.insert( - PathBuf::from("foo.txt"), - FileChange::Add { - content: "hello\n".to_string(), - }, - ); - let begin = PatchApplyBeginEvent { - call_id: "c1".into(), - turn_id: "turn-c1".into(), - auto_approved: true, - changes: changes2, - }; - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::PatchApplyBegin(begin), - }); - let cells = drain_insert_history(&mut rx); - assert!(!cells.is_empty(), "expected apply block cell to be sent"); - let blob = lines_to_single_string(cells.last().unwrap()); - assert!( - blob.contains("Added foo.txt") || blob.contains("Edited foo.txt"), - "expected single-file header with filename (Added/Edited): {blob:?}" - ); - - // 3) End apply success -> success cell - let mut end_changes = HashMap::new(); - end_changes.insert( - PathBuf::from("foo.txt"), - FileChange::Add { - content: "hello\n".to_string(), - }, - ); - let end = PatchApplyEndEvent { - call_id: "c1".into(), - turn_id: "turn-c1".into(), - stdout: "ok\n".into(), - stderr: String::new(), - success: true, - changes: end_changes, - }; - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::PatchApplyEnd(end), - }); - let cells = drain_insert_history(&mut rx); - assert!( - cells.is_empty(), - "no success cell should be emitted anymore" - ); -} - -#[tokio::test] -async fn apply_patch_manual_approval_adjusts_header() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - let mut proposed_changes = HashMap::new(); - proposed_changes.insert( - PathBuf::from("foo.txt"), - FileChange::Add { - content: "hello\n".to_string(), - }, - ); - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { - call_id: "c1".into(), - turn_id: "turn-c1".into(), - changes: proposed_changes, - reason: None, - grant_root: None, - }), - }); - drain_insert_history(&mut rx); - - let mut apply_changes = HashMap::new(); - apply_changes.insert( - PathBuf::from("foo.txt"), - FileChange::Add { - content: "hello\n".to_string(), - }, - ); - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::PatchApplyBegin(PatchApplyBeginEvent { - call_id: "c1".into(), - turn_id: "turn-c1".into(), - auto_approved: false, - changes: apply_changes, - }), - }); - - let cells = drain_insert_history(&mut rx); - assert!(!cells.is_empty(), "expected apply block cell to be sent"); - let blob = lines_to_single_string(cells.last().unwrap()); - assert!( - blob.contains("Added foo.txt") || blob.contains("Edited foo.txt"), - "expected apply summary header for foo.txt: {blob:?}" - ); -} - -#[tokio::test] -async fn apply_patch_manual_flow_snapshot() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - let mut proposed_changes = HashMap::new(); - proposed_changes.insert( - PathBuf::from("foo.txt"), - FileChange::Add { - content: "hello\n".to_string(), - }, - ); - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { - call_id: "c1".into(), - turn_id: "turn-c1".into(), - changes: proposed_changes, - reason: Some("Manual review required".into()), - grant_root: None, - }), - }); - let history_before_apply = drain_insert_history(&mut rx); - assert!( - history_before_apply.is_empty(), - "expected approval modal to defer history emission" - ); - - let mut apply_changes = HashMap::new(); - apply_changes.insert( - PathBuf::from("foo.txt"), - FileChange::Add { - content: "hello\n".to_string(), - }, - ); - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::PatchApplyBegin(PatchApplyBeginEvent { - call_id: "c1".into(), - turn_id: "turn-c1".into(), - auto_approved: false, - changes: apply_changes, - }), - }); - let approved_lines = drain_insert_history(&mut rx) - .pop() - .expect("approved patch cell"); - - assert_snapshot!( - "apply_patch_manual_flow_history_approved", - lines_to_single_string(&approved_lines) - ); -} - -#[tokio::test] -async fn apply_patch_approval_sends_op_with_submission_id() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - // Simulate receiving an approval request with a distinct submission id and call id - let mut changes = HashMap::new(); - changes.insert( - PathBuf::from("file.rs"), - FileChange::Add { - content: "fn main(){}\n".into(), - }, - ); - let ev = ApplyPatchApprovalRequestEvent { - call_id: "call-999".into(), - turn_id: "turn-999".into(), - changes, - reason: None, - grant_root: None, - }; - chat.handle_codex_event(Event { - id: "sub-123".into(), - msg: EventMsg::ApplyPatchApprovalRequest(ev), - }); - - // Approve via key press 'y' - chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); - - // Expect a CodexOp with PatchApproval carrying the submission id, not call id - let mut found = false; - while let Ok(app_ev) = rx.try_recv() { - if let AppEvent::CodexOp(Op::PatchApproval { id, decision }) = app_ev { - assert_eq!(id, "sub-123"); - assert_matches!(decision, codex_core::protocol::ReviewDecision::Approved); - found = true; - break; - } - } - assert!(found, "expected PatchApproval op to be sent"); -} - -#[tokio::test] -async fn apply_patch_full_flow_integration_like() { - let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; - - // 1) Backend requests approval - let mut changes = HashMap::new(); - changes.insert( - PathBuf::from("pkg.rs"), - FileChange::Add { content: "".into() }, - ); - chat.handle_codex_event(Event { - id: "sub-xyz".into(), - msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { - call_id: "call-1".into(), - turn_id: "turn-call-1".into(), - changes, - reason: None, - grant_root: None, - }), - }); - - // 2) User approves via 'y' and App receives a CodexOp - chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); - let mut maybe_op: Option = None; - while let Ok(app_ev) = rx.try_recv() { - if let AppEvent::CodexOp(op) = app_ev { - maybe_op = Some(op); - break; - } - } - let op = maybe_op.expect("expected CodexOp after key press"); - - // 3) App forwards to widget.submit_op, which pushes onto codex_op_tx - chat.submit_op(op); - let forwarded = op_rx - .try_recv() - .expect("expected op forwarded to codex channel"); - match forwarded { - Op::PatchApproval { id, decision } => { - assert_eq!(id, "sub-xyz"); - assert_matches!(decision, codex_core::protocol::ReviewDecision::Approved); - } - other => panic!("unexpected op forwarded: {other:?}"), - } - - // 4) Simulate patch begin/end events from backend; ensure history cells are emitted - let mut changes2 = HashMap::new(); - changes2.insert( - PathBuf::from("pkg.rs"), - FileChange::Add { content: "".into() }, - ); - chat.handle_codex_event(Event { - id: "sub-xyz".into(), - msg: EventMsg::PatchApplyBegin(PatchApplyBeginEvent { - call_id: "call-1".into(), - turn_id: "turn-call-1".into(), - auto_approved: false, - changes: changes2, - }), - }); - let mut end_changes = HashMap::new(); - end_changes.insert( - PathBuf::from("pkg.rs"), - FileChange::Add { content: "".into() }, - ); - chat.handle_codex_event(Event { - id: "sub-xyz".into(), - msg: EventMsg::PatchApplyEnd(PatchApplyEndEvent { - call_id: "call-1".into(), - turn_id: "turn-call-1".into(), - stdout: String::from("ok"), - stderr: String::new(), - success: true, - changes: end_changes, - }), - }); -} - -#[tokio::test] -async fn apply_patch_untrusted_shows_approval_modal() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - // Ensure approval policy is untrusted (OnRequest) - chat.config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); - - // Simulate a patch approval request from backend - let mut changes = HashMap::new(); - changes.insert( - PathBuf::from("a.rs"), - FileChange::Add { content: "".into() }, - ); - chat.handle_codex_event(Event { - id: "sub-1".into(), - msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { - call_id: "call-1".into(), - turn_id: "turn-call-1".into(), - changes, - reason: None, - grant_root: None, - }), - }); - - // Render and ensure the approval modal title is present - let area = Rect::new(0, 0, 80, 12); - let mut buf = Buffer::empty(area); - chat.render(area, &mut buf); - - let mut contains_title = false; - for y in 0..area.height { - let mut row = String::new(); - for x in 0..area.width { - row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); - } - if row.contains("Would you like to make the following edits?") { - contains_title = true; - break; - } - } - assert!( - contains_title, - "expected approval modal to be visible with title 'Would you like to make the following edits?'" - ); -} - -#[tokio::test] -async fn apply_patch_request_shows_diff_summary() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - // Ensure we are in OnRequest so an approval is surfaced - chat.config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); - - // Simulate backend asking to apply a patch adding two lines to README.md - let mut changes = HashMap::new(); - changes.insert( - PathBuf::from("README.md"), - FileChange::Add { - // Two lines (no trailing empty line counted) - content: "line one\nline two\n".into(), - }, - ); - chat.handle_codex_event(Event { - id: "sub-apply".into(), - msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { - call_id: "call-apply".into(), - turn_id: "turn-apply".into(), - changes, - reason: None, - grant_root: None, - }), - }); - - // No history entries yet; the modal should contain the diff summary - let cells = drain_insert_history(&mut rx); - assert!( - cells.is_empty(), - "expected approval request to render via modal instead of history" - ); - - let area = Rect::new(0, 0, 80, chat.desired_height(80)); - let mut buf = ratatui::buffer::Buffer::empty(area); - chat.render(area, &mut buf); - - let mut saw_header = false; - let mut saw_line1 = false; - let mut saw_line2 = false; - for y in 0..area.height { - let mut row = String::new(); - for x in 0..area.width { - row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); - } - if row.contains("README.md (+2 -0)") { - saw_header = true; - } - if row.contains("+line one") { - saw_line1 = true; - } - if row.contains("+line two") { - saw_line2 = true; - } - if saw_header && saw_line1 && saw_line2 { - break; - } - } - assert!(saw_header, "expected modal to show diff header with totals"); - assert!( - saw_line1 && saw_line2, - "expected modal to show per-line diff summary" - ); -} - -#[tokio::test] -async fn plan_update_renders_history_cell() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - let update = UpdatePlanArgs { - explanation: Some("Adapting plan".to_string()), - plan: vec![ - PlanItemArg { - step: "Explore codebase".into(), - status: StepStatus::Completed, - }, - PlanItemArg { - step: "Implement feature".into(), - status: StepStatus::InProgress, - }, - PlanItemArg { - step: "Write tests".into(), - status: StepStatus::Pending, - }, - ], - }; - chat.handle_codex_event(Event { - id: "sub-1".into(), - msg: EventMsg::PlanUpdate(update), - }); - let cells = drain_insert_history(&mut rx); - assert!(!cells.is_empty(), "expected plan update cell to be sent"); - let blob = lines_to_single_string(cells.last().unwrap()); - assert!( - blob.contains("Updated Plan"), - "missing plan header: {blob:?}" - ); - assert!(blob.contains("Explore codebase")); - assert!(blob.contains("Implement feature")); - assert!(blob.contains("Write tests")); -} - -#[tokio::test] -async fn stream_error_updates_status_indicator() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - chat.bottom_pane.set_task_running(true); - let msg = "Reconnecting... 2/5"; - let details = "Idle timeout waiting for SSE"; - chat.handle_codex_event(Event { - id: "sub-1".into(), - msg: EventMsg::StreamError(StreamErrorEvent { - message: msg.to_string(), - codex_error_info: Some(CodexErrorInfo::Other), - additional_details: Some(details.to_string()), - }), - }); - - let cells = drain_insert_history(&mut rx); - assert!( - cells.is_empty(), - "expected no history cell for StreamError event" - ); - let status = chat - .bottom_pane - .status_widget() - .expect("status indicator should be visible"); - assert_eq!(status.header(), msg); - assert_eq!(status.details(), Some(details)); -} - -#[tokio::test] -async fn warning_event_adds_warning_history_cell() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - chat.handle_codex_event(Event { - id: "sub-1".into(), - msg: EventMsg::Warning(WarningEvent { - message: "test warning message".to_string(), - }), - }); - - let cells = drain_insert_history(&mut rx); - assert_eq!(cells.len(), 1, "expected one warning history cell"); - let rendered = lines_to_single_string(&cells[0]); - assert!( - rendered.contains("test warning message"), - "warning cell missing content: {rendered}" - ); -} - -#[tokio::test] -async fn stream_recovery_restores_previous_status_header() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - chat.handle_codex_event(Event { - id: "task".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - model_context_window: None, - }), - }); - drain_insert_history(&mut rx); - chat.handle_codex_event(Event { - id: "retry".into(), - msg: EventMsg::StreamError(StreamErrorEvent { - message: "Reconnecting... 1/5".to_string(), - codex_error_info: Some(CodexErrorInfo::Other), - additional_details: None, - }), - }); - drain_insert_history(&mut rx); - chat.handle_codex_event(Event { - id: "delta".into(), - msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { - delta: "hello".to_string(), - }), - }); - - let status = chat - .bottom_pane - .status_widget() - .expect("status indicator should be visible"); - assert_eq!(status.header(), "Working"); - assert_eq!(status.details(), None); - assert!(chat.retry_status_header.is_none()); -} - -#[tokio::test] -async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - // Begin turn - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - model_context_window: None, - }), - }); - - // First finalized assistant message - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::AgentMessage(AgentMessageEvent { - message: "First message".into(), - }), - }); - - // Second finalized assistant message in the same turn - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::AgentMessage(AgentMessageEvent { - message: "Second message".into(), - }), - }); - - // End turn - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::TurnComplete(TurnCompleteEvent { - last_agent_message: None, - }), - }); - - let cells = drain_insert_history(&mut rx); - let combined: String = cells - .iter() - .map(|lines| lines_to_single_string(lines)) - .collect(); - assert!( - combined.contains("First message"), - "missing first message: {combined}" - ); - assert!( - combined.contains("Second message"), - "missing second message: {combined}" - ); - let first_idx = combined.find("First message").unwrap(); - let second_idx = combined.find("Second message").unwrap(); - assert!(first_idx < second_idx, "messages out of order: {combined}"); -} - -#[tokio::test] -async fn final_reasoning_then_message_without_deltas_are_rendered() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - // No deltas; only final reasoning followed by final message. - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::AgentReasoning(AgentReasoningEvent { - text: "I will first analyze the request.".into(), - }), - }); - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::AgentMessage(AgentMessageEvent { - message: "Here is the result.".into(), - }), - }); - - // Drain history and snapshot the combined visible content. - let cells = drain_insert_history(&mut rx); - let combined = cells - .iter() - .map(|lines| lines_to_single_string(lines)) - .collect::(); - assert_snapshot!(combined); -} - -#[tokio::test] -async fn deltas_then_same_final_message_are_rendered_snapshot() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - // Stream some reasoning deltas first. - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { - delta: "I will ".into(), - }), - }); - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { - delta: "first analyze the ".into(), - }), - }); - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { - delta: "request.".into(), - }), - }); - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::AgentReasoning(AgentReasoningEvent { - text: "request.".into(), - }), - }); - - // Then stream answer deltas, followed by the exact same final message. - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { - delta: "Here is the ".into(), - }), - }); - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { - delta: "result.".into(), - }), - }); - - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::AgentMessage(AgentMessageEvent { - message: "Here is the result.".into(), - }), - }); - - // Snapshot the combined visible content to ensure we render as expected - // when deltas are followed by the identical final message. - let cells = drain_insert_history(&mut rx); - let combined = cells - .iter() - .map(|lines| lines_to_single_string(lines)) - .collect::(); - assert_snapshot!(combined); -} - -// Combined visual snapshot using vt100 for history + direct buffer overlay for UI. -// This renders the final visual as seen in a terminal: history above, then a blank line, -// then the exec block, another blank line, the status line, a blank line, and the composer. -#[tokio::test] -async fn chatwidget_exec_and_status_layout_vt100_snapshot() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - chat.handle_codex_event(Event { - id: "t1".into(), - msg: EventMsg::AgentMessage(AgentMessageEvent { message: "I’m going to search the repo for where “Change Approved” is rendered to update that view.".into() }), - }); - - let command = vec!["bash".into(), "-lc".into(), "rg \"Change Approved\"".into()]; - let parsed_cmd = vec![ - ParsedCommand::Search { - query: Some("Change Approved".into()), - path: None, - cmd: "rg \"Change Approved\"".into(), - }, - ParsedCommand::Read { - name: "diff_render.rs".into(), - cmd: "cat diff_render.rs".into(), - path: "diff_render.rs".into(), - }, - ]; - let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - chat.handle_codex_event(Event { - id: "c1".into(), - msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent { - call_id: "c1".into(), - process_id: None, - turn_id: "turn-1".into(), - command: command.clone(), - cwd: cwd.clone(), - parsed_cmd: parsed_cmd.clone(), - source: ExecCommandSource::Agent, - interaction_input: None, - }), - }); - chat.handle_codex_event(Event { - id: "c1".into(), - msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent { - call_id: "c1".into(), - process_id: None, - turn_id: "turn-1".into(), - command, - cwd, - parsed_cmd, - source: ExecCommandSource::Agent, - interaction_input: None, - stdout: String::new(), - stderr: String::new(), - aggregated_output: String::new(), - exit_code: 0, - duration: std::time::Duration::from_millis(16000), - formatted_output: String::new(), - }), - }); - chat.handle_codex_event(Event { - id: "t1".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - model_context_window: None, - }), - }); - chat.handle_codex_event(Event { - id: "t1".into(), - msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { - delta: "**Investigating rendering code**".into(), - }), - }); - chat.bottom_pane.set_composer_text( - "Summarize recent commits".to_string(), - Vec::new(), - Vec::new(), - ); - - let width: u16 = 80; - let ui_height: u16 = chat.desired_height(width); - let vt_height: u16 = 40; - let viewport = Rect::new(0, vt_height - ui_height - 1, width, ui_height); - - let backend = VT100Backend::new(width, vt_height); - let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); - term.set_viewport_area(viewport); - - for lines in drain_insert_history(&mut rx) { - crate::insert_history::insert_history_lines(&mut term, lines) - .expect("Failed to insert history lines in test"); - } - - term.draw(|f| { - chat.render(f.area(), f.buffer_mut()); - }) - .unwrap(); - - assert_snapshot!(term.backend().vt100().screen().contents()); -} - -// E2E vt100 snapshot for complex markdown with indented and nested fenced code blocks -#[tokio::test] -async fn chatwidget_markdown_code_blocks_vt100_snapshot() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - // Simulate a final agent message via streaming deltas instead of a single message - - chat.handle_codex_event(Event { - id: "t1".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - model_context_window: None, - }), - }); - // Build a vt100 visual from the history insertions only (no UI overlay) - let width: u16 = 80; - let height: u16 = 50; - let backend = VT100Backend::new(width, height); - let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); - // Place viewport at the last line so that history lines insert above it - term.set_viewport_area(Rect::new(0, height - 1, width, 1)); - - // Simulate streaming via AgentMessageDelta in 2-character chunks (no final AgentMessage). - let source: &str = r#" - - -- Indented code block (4 spaces) - SELECT * - FROM "users" - WHERE "email" LIKE '%@example.com'; - -````markdown -```sh -printf 'fenced within fenced\n' -``` -```` - -```jsonc -{ - // comment allowed in jsonc - "path": "C:\\Program Files\\App", - "regex": "^foo.*(bar)?$" -} -``` -"#; - - let mut it = source.chars(); - loop { - let mut delta = String::new(); - match it.next() { - Some(c) => delta.push(c), - None => break, - } - if let Some(c2) = it.next() { - delta.push(c2); - } - - chat.handle_codex_event(Event { - id: "t1".into(), - msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }), - }); - // Drive commit ticks and drain emitted history lines into the vt100 buffer. - loop { - chat.on_commit_tick(); - let mut inserted_any = false; - while let Ok(app_ev) = rx.try_recv() { - if let AppEvent::InsertHistoryCell(cell) = app_ev { - let lines = cell.display_lines(width); - crate::insert_history::insert_history_lines(&mut term, lines) - .expect("Failed to insert history lines in test"); - inserted_any = true; - } - } - if !inserted_any { - break; - } - } - } - - // Finalize the stream without sending a final AgentMessage, to flush any tail. - chat.handle_codex_event(Event { - id: "t1".into(), - msg: EventMsg::TurnComplete(TurnCompleteEvent { - last_agent_message: None, - }), - }); - for lines in drain_insert_history(&mut rx) { - crate::insert_history::insert_history_lines(&mut term, lines) - .expect("Failed to insert history lines in test"); - } - - assert_snapshot!(term.backend().vt100().screen().contents()); -} - -#[tokio::test] -async fn chatwidget_tall() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - chat.conversation_id = Some(ThreadId::new()); - chat.handle_codex_event(Event { - id: "t1".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - model_context_window: None, - }), - }); - for i in 0..30 { - chat.queue_user_message(format!("Hello, world! {i}").into()); - } - let width: u16 = 80; - let height: u16 = 24; - let backend = VT100Backend::new(width, height); - let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); - let desired_height = chat.desired_height(width).min(height); - term.set_viewport_area(Rect::new(0, height - desired_height, width, desired_height)); - term.draw(|f| { - chat.render(f.area(), f.buffer_mut()); - }) - .unwrap(); - assert_snapshot!(term.backend().vt100().screen().contents()); -} diff --git a/codex-rs/tui2/src/cli.rs b/codex-rs/tui2/src/cli.rs deleted file mode 100644 index 77903c70462..00000000000 --- a/codex-rs/tui2/src/cli.rs +++ /dev/null @@ -1,142 +0,0 @@ -use clap::Parser; -use clap::ValueHint; -use codex_common::ApprovalModeCliArg; -use codex_common::CliConfigOverrides; -use std::path::PathBuf; - -#[derive(Parser, Debug)] -#[command(version)] -pub struct Cli { - /// Optional user prompt to start the session. - #[arg(value_name = "PROMPT", value_hint = clap::ValueHint::Other)] - pub prompt: Option, - - /// Optional image(s) to attach to the initial prompt. - #[arg(long = "image", short = 'i', value_name = "FILE", value_delimiter = ',', num_args = 1..)] - pub images: Vec, - - // Internal controls set by the top-level `codex resume` subcommand. - // These are not exposed as user flags on the base `codex` command. - #[clap(skip)] - pub resume_picker: bool, - - #[clap(skip)] - pub resume_last: bool, - - /// Internal: resume a specific recorded session by id (UUID). Set by the - /// top-level `codex resume ` wrapper; not exposed as a public flag. - #[clap(skip)] - pub resume_session_id: Option, - - /// Internal: show all sessions (disables cwd filtering and shows CWD column). - #[clap(skip)] - pub resume_show_all: bool, - - // Internal controls set by the top-level `codex fork` subcommand. - // These are not exposed as user flags on the base `codex` command. - #[clap(skip)] - pub fork_picker: bool, - - #[clap(skip)] - pub fork_last: bool, - - /// Internal: fork a specific recorded session by id (UUID). Set by the - /// top-level `codex fork ` wrapper; not exposed as a public flag. - #[clap(skip)] - pub fork_session_id: Option, - - /// Internal: show all sessions (disables cwd filtering and shows CWD column). - #[clap(skip)] - pub fork_show_all: bool, - - /// Model the agent should use. - #[arg(long, short = 'm')] - pub model: Option, - - /// Convenience flag to select the local open source model provider. Equivalent to -c - /// model_provider=oss; verifies a local LM Studio or Ollama server is running. - #[arg(long = "oss", default_value_t = false)] - pub oss: bool, - - /// Specify which local provider to use (lmstudio, ollama, or ollama-chat). - /// If not specified with --oss, will use config default or show selection. - #[arg(long = "local-provider")] - pub oss_provider: Option, - - /// Configuration profile from config.toml to specify default options. - #[arg(long = "profile", short = 'p')] - pub config_profile: Option, - - /// Select the sandbox policy to use when executing model-generated shell - /// commands. - #[arg(long = "sandbox", short = 's')] - pub sandbox_mode: Option, - - /// Configure when the model requires human approval before executing a command. - #[arg(long = "ask-for-approval", short = 'a')] - pub approval_policy: Option, - - /// Convenience alias for low-friction sandboxed automatic execution (-a on-request, --sandbox workspace-write). - #[arg(long = "full-auto", default_value_t = false)] - pub full_auto: bool, - - /// Skip all confirmation prompts and execute commands without sandboxing. - /// EXTREMELY DANGEROUS. Intended solely for running in environments that are externally sandboxed. - #[arg( - long = "dangerously-bypass-approvals-and-sandbox", - alias = "yolo", - default_value_t = false, - conflicts_with_all = ["approval_policy", "full_auto"] - )] - pub dangerously_bypass_approvals_and_sandbox: bool, - - /// Tell the agent to use the specified directory as its working root. - #[clap(long = "cd", short = 'C', value_name = "DIR")] - pub cwd: Option, - - /// Enable live web search. When enabled, the native Responses `web_search` tool is available to the model (no per‑call approval). - #[arg(long = "search", default_value_t = false)] - pub web_search: bool, - - /// Additional directories that should be writable alongside the primary workspace. - #[arg(long = "add-dir", value_name = "DIR", value_hint = ValueHint::DirPath)] - pub add_dir: Vec, - - /// Disable alternate screen mode for better scrollback in terminal multiplexers like Zellij. - /// This runs the TUI in inline mode, preserving terminal scrollback history. - #[arg(long = "no-alt-screen", default_value_t = false)] - pub no_alt_screen: bool, - - #[clap(skip)] - pub config_overrides: CliConfigOverrides, -} - -impl From for Cli { - fn from(cli: codex_tui::Cli) -> Self { - Self { - prompt: cli.prompt, - images: cli.images, - resume_picker: cli.resume_picker, - resume_last: cli.resume_last, - resume_session_id: cli.resume_session_id, - resume_show_all: cli.resume_show_all, - fork_picker: cli.fork_picker, - fork_last: cli.fork_last, - fork_session_id: cli.fork_session_id, - fork_show_all: cli.fork_show_all, - model: cli.model, - oss: cli.oss, - oss_provider: cli.oss_provider, - config_profile: cli.config_profile, - sandbox_mode: cli.sandbox_mode, - approval_policy: cli.approval_policy, - full_auto: cli.full_auto, - dangerously_bypass_approvals_and_sandbox: cli.dangerously_bypass_approvals_and_sandbox, - cwd: cli.cwd, - web_search: cli.web_search, - add_dir: cli.add_dir, - no_alt_screen: cli.no_alt_screen, - config_overrides: cli.config_overrides, - } - } -} diff --git a/codex-rs/tui2/src/clipboard_copy.rs b/codex-rs/tui2/src/clipboard_copy.rs deleted file mode 100644 index 76718704e5f..00000000000 --- a/codex-rs/tui2/src/clipboard_copy.rs +++ /dev/null @@ -1,79 +0,0 @@ -use tracing::error; - -#[derive(Debug)] -pub enum ClipboardError { - ClipboardUnavailable(String), - WriteFailed(String), -} - -impl std::fmt::Display for ClipboardError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ClipboardError::ClipboardUnavailable(msg) => { - write!(f, "clipboard unavailable: {msg}") - } - ClipboardError::WriteFailed(msg) => write!(f, "failed to write to clipboard: {msg}"), - } - } -} - -impl std::error::Error for ClipboardError {} - -pub trait ClipboardManager { - fn set_text(&mut self, text: String) -> Result<(), ClipboardError>; -} - -#[cfg(not(target_os = "android"))] -pub struct ArboardClipboardManager { - inner: Option, -} - -#[cfg(not(target_os = "android"))] -impl ArboardClipboardManager { - pub fn new() -> Self { - match arboard::Clipboard::new() { - Ok(cb) => Self { inner: Some(cb) }, - Err(err) => { - error!(error = %err, "failed to initialize clipboard"); - Self { inner: None } - } - } - } -} - -#[cfg(not(target_os = "android"))] -impl ClipboardManager for ArboardClipboardManager { - fn set_text(&mut self, text: String) -> Result<(), ClipboardError> { - let Some(cb) = &mut self.inner else { - return Err(ClipboardError::ClipboardUnavailable( - "clipboard is not available in this environment".to_string(), - )); - }; - cb.set_text(text) - .map_err(|e| ClipboardError::WriteFailed(e.to_string())) - } -} - -#[cfg(target_os = "android")] -pub struct ArboardClipboardManager; - -#[cfg(target_os = "android")] -impl ArboardClipboardManager { - pub fn new() -> Self { - ArboardClipboardManager - } -} - -#[cfg(target_os = "android")] -impl ClipboardManager for ArboardClipboardManager { - fn set_text(&mut self, _text: String) -> Result<(), ClipboardError> { - Err(ClipboardError::ClipboardUnavailable( - "clipboard text copy is unsupported on Android".to_string(), - )) - } -} - -pub fn copy_text(text: String) -> Result<(), ClipboardError> { - let mut manager = ArboardClipboardManager::new(); - manager.set_text(text) -} diff --git a/codex-rs/tui2/src/clipboard_paste.rs b/codex-rs/tui2/src/clipboard_paste.rs deleted file mode 100644 index 4d28b365fed..00000000000 --- a/codex-rs/tui2/src/clipboard_paste.rs +++ /dev/null @@ -1,549 +0,0 @@ -use std::path::Path; -use std::path::PathBuf; -use tempfile::Builder; - -#[derive(Debug, Clone)] -pub enum PasteImageError { - ClipboardUnavailable(String), - NoImage(String), - EncodeFailed(String), - IoError(String), -} - -impl std::fmt::Display for PasteImageError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - PasteImageError::ClipboardUnavailable(msg) => write!(f, "clipboard unavailable: {msg}"), - PasteImageError::NoImage(msg) => write!(f, "no image on clipboard: {msg}"), - PasteImageError::EncodeFailed(msg) => write!(f, "could not encode image: {msg}"), - PasteImageError::IoError(msg) => write!(f, "io error: {msg}"), - } - } -} -impl std::error::Error for PasteImageError {} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum EncodedImageFormat { - Png, - Jpeg, - Other, -} - -impl EncodedImageFormat { - pub fn label(self) -> &'static str { - match self { - EncodedImageFormat::Png => "PNG", - EncodedImageFormat::Jpeg => "JPEG", - EncodedImageFormat::Other => "IMG", - } - } -} - -#[derive(Debug, Clone)] -pub struct PastedImageInfo { - pub width: u32, - pub height: u32, - pub encoded_format: EncodedImageFormat, // Always PNG for now. -} - -/// Capture image from system clipboard, encode to PNG, and return bytes + info. -#[cfg(not(target_os = "android"))] -pub fn paste_image_as_png() -> Result<(Vec, PastedImageInfo), PasteImageError> { - let _span = tracing::debug_span!("paste_image_as_png").entered(); - tracing::debug!("attempting clipboard image read"); - let mut cb = arboard::Clipboard::new() - .map_err(|e| PasteImageError::ClipboardUnavailable(e.to_string()))?; - // Sometimes images on the clipboard come as files (e.g. when copy/pasting from - // Finder), sometimes they come as image data (e.g. when pasting from Chrome). - // Accept both, and prefer files if both are present. - let files = cb - .get() - .file_list() - .map_err(|e| PasteImageError::ClipboardUnavailable(e.to_string())); - let dyn_img = if let Some(img) = files - .unwrap_or_default() - .into_iter() - .find_map(|f| image::open(f).ok()) - { - tracing::debug!( - "clipboard image opened from file: {}x{}", - img.width(), - img.height() - ); - img - } else { - let _span = tracing::debug_span!("get_image").entered(); - let img = cb - .get_image() - .map_err(|e| PasteImageError::NoImage(e.to_string()))?; - let w = img.width as u32; - let h = img.height as u32; - tracing::debug!("clipboard image opened from image: {}x{}", w, h); - - let Some(rgba_img) = image::RgbaImage::from_raw(w, h, img.bytes.into_owned()) else { - return Err(PasteImageError::EncodeFailed("invalid RGBA buffer".into())); - }; - - image::DynamicImage::ImageRgba8(rgba_img) - }; - - let mut png: Vec = Vec::new(); - { - let span = - tracing::debug_span!("encode_image", byte_length = tracing::field::Empty).entered(); - let mut cursor = std::io::Cursor::new(&mut png); - dyn_img - .write_to(&mut cursor, image::ImageFormat::Png) - .map_err(|e| PasteImageError::EncodeFailed(e.to_string()))?; - span.record("byte_length", png.len()); - } - - Ok(( - png, - PastedImageInfo { - width: dyn_img.width(), - height: dyn_img.height(), - encoded_format: EncodedImageFormat::Png, - }, - )) -} - -/// Android/Termux does not support arboard; return a clear error. -#[cfg(target_os = "android")] -pub fn paste_image_as_png() -> Result<(Vec, PastedImageInfo), PasteImageError> { - Err(PasteImageError::ClipboardUnavailable( - "clipboard image paste is unsupported on Android".into(), - )) -} - -/// Convenience: write to a temp file and return its path + info. -#[cfg(not(target_os = "android"))] -pub fn paste_image_to_temp_png() -> Result<(PathBuf, PastedImageInfo), PasteImageError> { - // First attempt: read image from system clipboard via arboard (native paths or image data). - match paste_image_as_png() { - Ok((png, info)) => { - // Create a unique temporary file with a .png suffix to avoid collisions. - let tmp = Builder::new() - .prefix("codex-clipboard-") - .suffix(".png") - .tempfile() - .map_err(|e| PasteImageError::IoError(e.to_string()))?; - std::fs::write(tmp.path(), &png) - .map_err(|e| PasteImageError::IoError(e.to_string()))?; - // Persist the file (so it remains after the handle is dropped) and return its PathBuf. - let (_file, path) = tmp - .keep() - .map_err(|e| PasteImageError::IoError(e.error.to_string()))?; - Ok((path, info)) - } - Err(e) => { - #[cfg(target_os = "linux")] - { - try_wsl_clipboard_fallback(&e).or(Err(e)) - } - #[cfg(not(target_os = "linux"))] - { - Err(e) - } - } - } -} - -/// Attempt WSL fallback for clipboard image paste. -/// -/// If clipboard is unavailable (common under WSL because arboard cannot access -/// the Windows clipboard), attempt a WSL fallback that calls PowerShell on the -/// Windows side to write the clipboard image to a temporary file, then return -/// the corresponding WSL path. -#[cfg(target_os = "linux")] -fn try_wsl_clipboard_fallback( - error: &PasteImageError, -) -> Result<(PathBuf, PastedImageInfo), PasteImageError> { - use PasteImageError::ClipboardUnavailable; - use PasteImageError::NoImage; - - if !is_probably_wsl() || !matches!(error, ClipboardUnavailable(_) | NoImage(_)) { - return Err(error.clone()); - } - - tracing::debug!("attempting Windows PowerShell clipboard fallback"); - let Some(win_path) = try_dump_windows_clipboard_image() else { - return Err(error.clone()); - }; - - tracing::debug!("powershell produced path: {}", win_path); - let Some(mapped_path) = convert_windows_path_to_wsl(&win_path) else { - return Err(error.clone()); - }; - - let Ok((w, h)) = image::image_dimensions(&mapped_path) else { - return Err(error.clone()); - }; - - // Return the mapped path directly without copying. - // The file will be read and base64-encoded during serialization. - Ok(( - mapped_path, - PastedImageInfo { - width: w, - height: h, - encoded_format: EncodedImageFormat::Png, - }, - )) -} - -/// Try to call a Windows PowerShell command (several common names) to save the -/// clipboard image to a temporary PNG and return the Windows path to that file. -/// Returns None if no command succeeded or no image was present. -#[cfg(target_os = "linux")] -fn try_dump_windows_clipboard_image() -> Option { - // Powershell script: save image from clipboard to a temp png and print the path. - // Force UTF-8 output to avoid encoding issues between powershell.exe (UTF-16LE default) - // and pwsh (UTF-8 default). - let script = r#"[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; $img = Get-Clipboard -Format Image; if ($img -ne $null) { $p=[System.IO.Path]::GetTempFileName(); $p = [System.IO.Path]::ChangeExtension($p,'png'); $img.Save($p,[System.Drawing.Imaging.ImageFormat]::Png); Write-Output $p } else { exit 1 }"#; - - for cmd in ["powershell.exe", "pwsh", "powershell"] { - match std::process::Command::new(cmd) - .args(["-NoProfile", "-Command", script]) - .output() - { - // Executing PowerShell command - Ok(output) => { - if output.status.success() { - // Decode as UTF-8 (forced by the script above). - let win_path = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if !win_path.is_empty() { - tracing::debug!("{} saved clipboard image to {}", cmd, win_path); - return Some(win_path); - } - } else { - tracing::debug!("{} returned non-zero status", cmd); - } - } - Err(err) => { - tracing::debug!("{} not executable: {}", cmd, err); - } - } - } - None -} - -#[cfg(target_os = "android")] -pub fn paste_image_to_temp_png() -> Result<(PathBuf, PastedImageInfo), PasteImageError> { - // Keep error consistent with paste_image_as_png. - Err(PasteImageError::ClipboardUnavailable( - "clipboard image paste is unsupported on Android".into(), - )) -} - -/// Normalize pasted text that may represent a filesystem path. -/// -/// Supports: -/// - `file://` URLs (converted to local paths) -/// - Windows/UNC paths -/// - shell-escaped single paths (via `shlex`) -pub fn normalize_pasted_path(pasted: &str) -> Option { - let pasted = pasted.trim(); - let unquoted = pasted - .strip_prefix('"') - .and_then(|s| s.strip_suffix('"')) - .or_else(|| pasted.strip_prefix('\'').and_then(|s| s.strip_suffix('\''))) - .unwrap_or(pasted); - - // file:// URL → filesystem path - if let Ok(url) = url::Url::parse(unquoted) - && url.scheme() == "file" - { - return url.to_file_path().ok(); - } - - // TODO: We'll improve the implementation/unit tests over time, as appropriate. - // Possibly use typed-path: https://github.com/openai/codex/pull/2567/commits/3cc92b78e0a1f94e857cf4674d3a9db918ed352e - // - // Detect unquoted Windows paths and bypass POSIX shlex which - // treats backslashes as escapes (e.g., C:\Users\Alice\file.png). - // Also handles UNC paths (\\server\share\path). - if let Some(path) = normalize_windows_path(unquoted) { - return Some(path); - } - - // shell-escaped single path → unescaped - let parts: Vec = shlex::Shlex::new(pasted).collect(); - if parts.len() == 1 { - let part = parts.into_iter().next()?; - if let Some(path) = normalize_windows_path(&part) { - return Some(path); - } - return Some(PathBuf::from(part)); - } - - None -} - -#[cfg(target_os = "linux")] -pub(crate) fn is_probably_wsl() -> bool { - // Primary: Check /proc/version for "microsoft" or "WSL" (most reliable for standard WSL). - if let Ok(version) = std::fs::read_to_string("/proc/version") { - let version_lower = version.to_lowercase(); - if version_lower.contains("microsoft") || version_lower.contains("wsl") { - return true; - } - } - - // Fallback: Check WSL environment variables. This handles edge cases like - // custom Linux kernels installed in WSL where /proc/version may not contain - // "microsoft" or "WSL". - std::env::var_os("WSL_DISTRO_NAME").is_some() || std::env::var_os("WSL_INTEROP").is_some() -} - -#[cfg(target_os = "linux")] -fn convert_windows_path_to_wsl(input: &str) -> Option { - if input.starts_with("\\\\") { - return None; - } - - let drive_letter = input.chars().next()?.to_ascii_lowercase(); - if !drive_letter.is_ascii_lowercase() { - return None; - } - - if input.get(1..2) != Some(":") { - return None; - } - - let mut result = PathBuf::from(format!("/mnt/{drive_letter}")); - for component in input - .get(2..)? - .trim_start_matches(['\\', '/']) - .split(['\\', '/']) - .filter(|component| !component.is_empty()) - { - result.push(component); - } - - Some(result) -} - -fn normalize_windows_path(input: &str) -> Option { - // Drive letter path: C:\ or C:/ - let drive = input - .chars() - .next() - .map(|c| c.is_ascii_alphabetic()) - .unwrap_or(false) - && input.get(1..2) == Some(":") - && input - .get(2..3) - .map(|s| s == "\\" || s == "/") - .unwrap_or(false); - // UNC path: \\server\share - let unc = input.starts_with("\\\\"); - if !drive && !unc { - return None; - } - - #[cfg(target_os = "linux")] - { - if is_probably_wsl() - && let Some(converted) = convert_windows_path_to_wsl(input) - { - return Some(converted); - } - } - - Some(PathBuf::from(input)) -} - -/// Infer an image format for the provided path based on its extension. -pub fn pasted_image_format(path: &Path) -> EncodedImageFormat { - match path - .extension() - .and_then(|e| e.to_str()) - .map(str::to_ascii_lowercase) - .as_deref() - { - Some("png") => EncodedImageFormat::Png, - Some("jpg") | Some("jpeg") => EncodedImageFormat::Jpeg, - _ => EncodedImageFormat::Other, - } -} - -#[cfg(test)] -mod pasted_paths_tests { - use super::*; - - #[cfg(not(windows))] - #[test] - fn normalize_file_url() { - let input = "file:///tmp/example.png"; - let result = normalize_pasted_path(input).expect("should parse file URL"); - assert_eq!(result, PathBuf::from("/tmp/example.png")); - } - - #[test] - fn normalize_file_url_windows() { - let input = r"C:\Temp\example.png"; - let result = normalize_pasted_path(input).expect("should parse file URL"); - #[cfg(target_os = "linux")] - let expected = if is_probably_wsl() - && let Some(converted) = convert_windows_path_to_wsl(input) - { - converted - } else { - PathBuf::from(r"C:\Temp\example.png") - }; - #[cfg(not(target_os = "linux"))] - let expected = PathBuf::from(r"C:\Temp\example.png"); - assert_eq!(result, expected); - } - - #[test] - fn normalize_shell_escaped_single_path() { - let input = "/home/user/My\\ File.png"; - let result = normalize_pasted_path(input).expect("should unescape shell-escaped path"); - assert_eq!(result, PathBuf::from("/home/user/My File.png")); - } - - #[test] - fn normalize_simple_quoted_path_fallback() { - let input = "\"/home/user/My File.png\""; - let result = normalize_pasted_path(input).expect("should trim simple quotes"); - assert_eq!(result, PathBuf::from("/home/user/My File.png")); - } - - #[test] - fn normalize_single_quoted_unix_path() { - let input = "'/home/user/My File.png'"; - let result = normalize_pasted_path(input).expect("should trim single quotes via shlex"); - assert_eq!(result, PathBuf::from("/home/user/My File.png")); - } - - #[test] - fn normalize_multiple_tokens_returns_none() { - // Two tokens after shell splitting → not a single path - let input = "/home/user/a\\ b.png /home/user/c.png"; - let result = normalize_pasted_path(input); - assert!(result.is_none()); - } - - #[test] - fn pasted_image_format_png_jpeg_unknown() { - assert_eq!( - pasted_image_format(Path::new("/a/b/c.PNG")), - EncodedImageFormat::Png - ); - assert_eq!( - pasted_image_format(Path::new("/a/b/c.jpg")), - EncodedImageFormat::Jpeg - ); - assert_eq!( - pasted_image_format(Path::new("/a/b/c.JPEG")), - EncodedImageFormat::Jpeg - ); - assert_eq!( - pasted_image_format(Path::new("/a/b/c")), - EncodedImageFormat::Other - ); - assert_eq!( - pasted_image_format(Path::new("/a/b/c.webp")), - EncodedImageFormat::Other - ); - } - - #[test] - fn normalize_single_quoted_windows_path() { - let input = r"'C:\\Users\\Alice\\My File.jpeg'"; - let unquoted = r"C:\\Users\\Alice\\My File.jpeg"; - let result = - normalize_pasted_path(input).expect("should trim single quotes on windows path"); - #[cfg(target_os = "linux")] - let expected = if is_probably_wsl() - && let Some(converted) = convert_windows_path_to_wsl(unquoted) - { - converted - } else { - PathBuf::from(unquoted) - }; - #[cfg(not(target_os = "linux"))] - let expected = PathBuf::from(unquoted); - assert_eq!(result, expected); - } - - #[test] - fn normalize_double_quoted_windows_path() { - let input = r#""C:\\Users\\Alice\\My File.jpeg""#; - let unquoted = r"C:\\Users\\Alice\\My File.jpeg"; - let result = - normalize_pasted_path(input).expect("should trim double quotes on windows path"); - #[cfg(target_os = "linux")] - let expected = if is_probably_wsl() - && let Some(converted) = convert_windows_path_to_wsl(unquoted) - { - converted - } else { - PathBuf::from(unquoted) - }; - #[cfg(not(target_os = "linux"))] - let expected = PathBuf::from(unquoted); - assert_eq!(result, expected); - } - - #[test] - fn normalize_unquoted_windows_path_with_spaces() { - let input = r"C:\\Users\\Alice\\My Pictures\\example image.png"; - let result = normalize_pasted_path(input).expect("should accept unquoted windows path"); - #[cfg(target_os = "linux")] - let expected = if is_probably_wsl() - && let Some(converted) = convert_windows_path_to_wsl(input) - { - converted - } else { - PathBuf::from(r"C:\\Users\\Alice\\My Pictures\\example image.png") - }; - #[cfg(not(target_os = "linux"))] - let expected = PathBuf::from(r"C:\\Users\\Alice\\My Pictures\\example image.png"); - assert_eq!(result, expected); - } - - #[test] - fn normalize_unc_windows_path() { - let input = r"\\\\server\\share\\folder\\file.jpg"; - let result = normalize_pasted_path(input).expect("should accept UNC windows path"); - assert_eq!( - result, - PathBuf::from(r"\\\\server\\share\\folder\\file.jpg") - ); - } - - #[test] - fn pasted_image_format_with_windows_style_paths() { - assert_eq!( - pasted_image_format(Path::new(r"C:\\a\\b\\c.PNG")), - EncodedImageFormat::Png - ); - assert_eq!( - pasted_image_format(Path::new(r"C:\\a\\b\\c.jpeg")), - EncodedImageFormat::Jpeg - ); - assert_eq!( - pasted_image_format(Path::new(r"C:\\a\\b\\noext")), - EncodedImageFormat::Other - ); - } - - #[cfg(target_os = "linux")] - #[test] - fn normalize_windows_path_in_wsl() { - // This test only runs on actual WSL systems - if !is_probably_wsl() { - // Skip test if not on WSL - return; - } - let input = r"C:\\Users\\Alice\\Pictures\\example image.png"; - let result = normalize_pasted_path(input).expect("should convert windows path on wsl"); - assert_eq!( - result, - PathBuf::from("/mnt/c/Users/Alice/Pictures/example image.png") - ); - } -} diff --git a/codex-rs/tui2/src/collab.rs b/codex-rs/tui2/src/collab.rs deleted file mode 100644 index 5a1a18f63c8..00000000000 --- a/codex-rs/tui2/src/collab.rs +++ /dev/null @@ -1,140 +0,0 @@ -use crate::history_cell::PlainHistoryCell; -use crate::render::line_utils::prefix_lines; -use crate::text_formatting::truncate_text; -use codex_core::protocol::AgentStatus; -use codex_core::protocol::CollabAgentInteractionEndEvent; -use codex_core::protocol::CollabAgentSpawnEndEvent; -use codex_core::protocol::CollabCloseEndEvent; -use codex_core::protocol::CollabWaitingBeginEvent; -use codex_core::protocol::CollabWaitingEndEvent; -use ratatui::style::Stylize; -use ratatui::text::Line; - -const COLLAB_PROMPT_PREVIEW_GRAPHEMES: usize = 160; - -pub(crate) fn spawn_end(ev: CollabAgentSpawnEndEvent) -> PlainHistoryCell { - let CollabAgentSpawnEndEvent { - call_id, - sender_thread_id, - new_thread_id, - prompt, - status, - } = ev; - let new_agent = new_thread_id - .map(|id| id.to_string()) - .unwrap_or_else(|| "none".to_string()); - let mut details = vec![ - detail_line("call", call_id), - detail_line("sender", sender_thread_id), - detail_line("new_agent", new_agent), - status_line(&status), - ]; - if let Some(line) = prompt_line(&prompt) { - details.push(line); - } - collab_event("Collab spawn", details) -} - -pub(crate) fn interaction_end(ev: CollabAgentInteractionEndEvent) -> PlainHistoryCell { - let CollabAgentInteractionEndEvent { - call_id, - sender_thread_id, - receiver_thread_id, - prompt, - status, - } = ev; - let mut details = vec![ - detail_line("call", call_id), - detail_line("sender", sender_thread_id), - detail_line("receiver", receiver_thread_id), - status_line(&status), - ]; - if let Some(line) = prompt_line(&prompt) { - details.push(line); - } - collab_event("Collab send input", details) -} - -pub(crate) fn waiting_begin(ev: CollabWaitingBeginEvent) -> PlainHistoryCell { - let CollabWaitingBeginEvent { - call_id, - sender_thread_id, - receiver_thread_ids, - } = ev; - let details = vec![ - detail_line("call", call_id), - detail_line("sender", sender_thread_id), - detail_line("receiver", format!("{receiver_thread_ids:?}")), - ]; - collab_event("Collab wait begin", details) -} - -pub(crate) fn waiting_end(ev: CollabWaitingEndEvent) -> PlainHistoryCell { - let CollabWaitingEndEvent { - call_id, - sender_thread_id, - statuses, - } = ev; - let details = vec![ - detail_line("call", call_id), - detail_line("sender", sender_thread_id), - detail_line("statuses", format!("{statuses:#?}")), - ]; - collab_event("Collab wait end", details) -} - -pub(crate) fn close_end(ev: CollabCloseEndEvent) -> PlainHistoryCell { - let CollabCloseEndEvent { - call_id, - sender_thread_id, - receiver_thread_id, - status, - } = ev; - let details = vec![ - detail_line("call", call_id), - detail_line("sender", sender_thread_id), - detail_line("receiver", receiver_thread_id), - status_line(&status), - ]; - collab_event("Collab close", details) -} - -fn collab_event(title: impl Into, details: Vec>) -> PlainHistoryCell { - let title = title.into(); - let mut lines: Vec> = vec![vec!["• ".dim(), title.bold()].into()]; - if !details.is_empty() { - lines.extend(prefix_lines(details, " └ ".dim(), " ".into())); - } - PlainHistoryCell::new(lines) -} - -fn detail_line(label: &str, value: impl std::fmt::Display) -> Line<'static> { - Line::from(format!("{label}: {value}").dim()) -} - -fn status_line(status: &AgentStatus) -> Line<'static> { - Line::from(format!("status: {}", status_text(status)).dim()) -} - -fn status_text(status: &AgentStatus) -> &'static str { - match status { - AgentStatus::PendingInit => "pending_init", - AgentStatus::Running => "running", - AgentStatus::Completed(_) => "completed", - AgentStatus::Errored(_) => "errored", - AgentStatus::Shutdown => "shutdown", - AgentStatus::NotFound => "not_found", - } -} - -fn prompt_line(prompt: &str) -> Option> { - let trimmed = prompt.trim(); - if trimmed.is_empty() { - None - } else { - Some(detail_line( - "prompt", - truncate_text(trimmed, COLLAB_PROMPT_PREVIEW_GRAPHEMES), - )) - } -} diff --git a/codex-rs/tui2/src/collaboration_modes.rs b/codex-rs/tui2/src/collaboration_modes.rs deleted file mode 100644 index f48ad92c912..00000000000 --- a/codex-rs/tui2/src/collaboration_modes.rs +++ /dev/null @@ -1,49 +0,0 @@ -use codex_core::models_manager::manager::ModelsManager; -use codex_protocol::config_types::CollaborationMode; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum ModeKind { - Plan, - PairProgramming, - Execute, - Custom, -} - -fn mode_kind(mode: &CollaborationMode) -> ModeKind { - match mode { - CollaborationMode::Plan(_) => ModeKind::Plan, - CollaborationMode::PairProgramming(_) => ModeKind::PairProgramming, - CollaborationMode::Execute(_) => ModeKind::Execute, - CollaborationMode::Custom(_) => ModeKind::Custom, - } -} - -pub(crate) fn default_mode(models_manager: &ModelsManager) -> Option { - let presets = models_manager.list_collaboration_modes(); - presets - .iter() - .find(|preset| matches!(preset, CollaborationMode::PairProgramming(_))) - .cloned() - .or_else(|| presets.into_iter().next()) -} - -pub(crate) fn same_variant(a: &CollaborationMode, b: &CollaborationMode) -> bool { - mode_kind(a) == mode_kind(b) -} - -/// Cycle to the next collaboration mode preset in list order. -pub(crate) fn next_mode( - models_manager: &ModelsManager, - current: &CollaborationMode, -) -> Option { - let presets = models_manager.list_collaboration_modes(); - if presets.is_empty() { - return None; - } - let current_kind = mode_kind(current); - let next_index = presets - .iter() - .position(|preset| mode_kind(preset) == current_kind) - .map_or(0, |idx| (idx + 1) % presets.len()); - presets.get(next_index).cloned() -} diff --git a/codex-rs/tui2/src/color.rs b/codex-rs/tui2/src/color.rs deleted file mode 100644 index f5121a1f6c6..00000000000 --- a/codex-rs/tui2/src/color.rs +++ /dev/null @@ -1,75 +0,0 @@ -pub(crate) fn is_light(bg: (u8, u8, u8)) -> bool { - let (r, g, b) = bg; - let y = 0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32; - y > 128.0 -} - -pub(crate) fn blend(fg: (u8, u8, u8), bg: (u8, u8, u8), alpha: f32) -> (u8, u8, u8) { - let r = (fg.0 as f32 * alpha + bg.0 as f32 * (1.0 - alpha)) as u8; - let g = (fg.1 as f32 * alpha + bg.1 as f32 * (1.0 - alpha)) as u8; - let b = (fg.2 as f32 * alpha + bg.2 as f32 * (1.0 - alpha)) as u8; - (r, g, b) -} - -/// Returns the perceptual color distance between two RGB colors. -/// Uses the CIE76 formula (Euclidean distance in Lab space approximation). -pub(crate) fn perceptual_distance(a: (u8, u8, u8), b: (u8, u8, u8)) -> f32 { - // Convert sRGB to linear RGB - fn srgb_to_linear(c: u8) -> f32 { - let c = c as f32 / 255.0; - if c <= 0.04045 { - c / 12.92 - } else { - ((c + 0.055) / 1.055).powf(2.4) - } - } - - // Convert RGB to XYZ - fn rgb_to_xyz(r: u8, g: u8, b: u8) -> (f32, f32, f32) { - let r = srgb_to_linear(r); - let g = srgb_to_linear(g); - let b = srgb_to_linear(b); - - let x = r * 0.4124 + g * 0.3576 + b * 0.1805; - let y = r * 0.2126 + g * 0.7152 + b * 0.0722; - let z = r * 0.0193 + g * 0.1192 + b * 0.9505; - (x, y, z) - } - - // Convert XYZ to Lab - fn xyz_to_lab(x: f32, y: f32, z: f32) -> (f32, f32, f32) { - // D65 reference white - let xr = x / 0.95047; - let yr = y / 1.00000; - let zr = z / 1.08883; - - fn f(t: f32) -> f32 { - if t > 0.008856 { - t.powf(1.0 / 3.0) - } else { - 7.787 * t + 16.0 / 116.0 - } - } - - let fx = f(xr); - let fy = f(yr); - let fz = f(zr); - - let l = 116.0 * fy - 16.0; - let a = 500.0 * (fx - fy); - let b = 200.0 * (fy - fz); - (l, a, b) - } - - let (x1, y1, z1) = rgb_to_xyz(a.0, a.1, a.2); - let (x2, y2, z2) = rgb_to_xyz(b.0, b.1, b.2); - - let (l1, a1, b1) = xyz_to_lab(x1, y1, z1); - let (l2, a2, b2) = xyz_to_lab(x2, y2, z2); - - let dl = l1 - l2; - let da = a1 - a2; - let db = b1 - b2; - - (dl * dl + da * da + db * db).sqrt() -} diff --git a/codex-rs/tui2/src/custom_terminal.rs b/codex-rs/tui2/src/custom_terminal.rs deleted file mode 100644 index 46d16a83f05..00000000000 --- a/codex-rs/tui2/src/custom_terminal.rs +++ /dev/null @@ -1,645 +0,0 @@ -// This is derived from `ratatui::Terminal`, which is licensed under the following terms: -// -// The MIT License (MIT) -// Copyright (c) 2016-2022 Florian Dehau -// Copyright (c) 2023-2025 The Ratatui Developers -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. -use std::io; -use std::io::Write; - -use crossterm::cursor::MoveTo; -use crossterm::queue; -use crossterm::style::Colors; -use crossterm::style::Print; -use crossterm::style::SetAttribute; -use crossterm::style::SetBackgroundColor; -use crossterm::style::SetColors; -use crossterm::style::SetForegroundColor; -use crossterm::terminal::Clear; -use derive_more::IsVariant; -use ratatui::backend::Backend; -use ratatui::backend::ClearType; -use ratatui::buffer::Buffer; -use ratatui::layout::Position; -use ratatui::layout::Rect; -use ratatui::layout::Size; -use ratatui::style::Color; -use ratatui::style::Modifier; -use ratatui::widgets::WidgetRef; - -#[derive(Debug, Hash)] -pub struct Frame<'a> { - /// Where should the cursor be after drawing this frame? - /// - /// If `None`, the cursor is hidden and its position is controlled by the backend. If `Some((x, - /// y))`, the cursor is shown and placed at `(x, y)` after the call to `Terminal::draw()`. - pub(crate) cursor_position: Option, - - /// The area of the viewport - pub(crate) viewport_area: Rect, - - /// The buffer that is used to draw the current frame - pub(crate) buffer: &'a mut Buffer, -} - -impl Frame<'_> { - /// The area of the current frame - /// - /// This is guaranteed not to change during rendering, so may be called multiple times. - /// - /// If your app listens for a resize event from the backend, it should ignore the values from - /// the event for any calculations that are used to render the current frame and use this value - /// instead as this is the area of the buffer that is used to render the current frame. - pub const fn area(&self) -> Rect { - self.viewport_area - } - - /// Render a [`WidgetRef`] to the current buffer using [`WidgetRef::render_ref`]. - /// - /// Usually the area argument is the size of the current frame or a sub-area of the current - /// frame (which can be obtained using [`Layout`] to split the total area). - #[allow(clippy::needless_pass_by_value)] - pub fn render_widget_ref(&mut self, widget: W, area: Rect) { - widget.render_ref(area, self.buffer); - } - - /// After drawing this frame, make the cursor visible and put it at the specified (x, y) - /// coordinates. If this method is not called, the cursor will be hidden. - /// - /// Note that this will interfere with calls to [`Terminal::hide_cursor`], - /// [`Terminal::show_cursor`], and [`Terminal::set_cursor_position`]. Pick one of the APIs and - /// stick with it. - /// - /// [`Terminal::hide_cursor`]: crate::Terminal::hide_cursor - /// [`Terminal::show_cursor`]: crate::Terminal::show_cursor - /// [`Terminal::set_cursor_position`]: crate::Terminal::set_cursor_position - pub fn set_cursor_position>(&mut self, position: P) { - self.cursor_position = Some(position.into()); - } - - /// Gets the buffer that this `Frame` draws into as a mutable reference. - pub fn buffer_mut(&mut self) -> &mut Buffer { - self.buffer - } -} - -#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)] -pub struct Terminal -where - B: Backend + Write, -{ - /// The backend used to interface with the terminal - backend: B, - /// Holds the results of the current and previous draw calls. The two are compared at the end - /// of each draw pass to output the necessary updates to the terminal - buffers: [Buffer; 2], - /// Index of the current buffer in the previous array - current: usize, - /// Whether the cursor is currently hidden - pub hidden_cursor: bool, - /// Area of the viewport - pub viewport_area: Rect, - /// Last known size of the terminal. Used to detect if the internal buffers have to be resized. - pub last_known_screen_size: Size, - /// Last known position of the cursor. Used to find the new area when the viewport is inlined - /// and the terminal resized. - pub last_known_cursor_pos: Position, -} - -impl Drop for Terminal -where - B: Backend, - B: Write, -{ - #[allow(clippy::print_stderr)] - fn drop(&mut self) { - // Attempt to restore the cursor state - if self.hidden_cursor - && let Err(err) = self.show_cursor() - { - eprintln!("Failed to show the cursor: {err}"); - } - } -} - -impl Terminal -where - B: Backend, - B: Write, -{ - /// Creates a new [`Terminal`] with the given [`Backend`] and [`TerminalOptions`]. - pub fn with_options(mut backend: B) -> io::Result { - let screen_size = backend.size()?; - let cursor_pos = backend.get_cursor_position()?; - Ok(Self { - backend, - buffers: [Buffer::empty(Rect::ZERO), Buffer::empty(Rect::ZERO)], - current: 0, - hidden_cursor: false, - viewport_area: Rect::new(0, cursor_pos.y, 0, 0), - last_known_screen_size: screen_size, - last_known_cursor_pos: cursor_pos, - }) - } - - /// Get a Frame object which provides a consistent view into the terminal state for rendering. - pub fn get_frame(&mut self) -> Frame<'_> { - Frame { - cursor_position: None, - viewport_area: self.viewport_area, - buffer: self.current_buffer_mut(), - } - } - - /// Gets the current buffer as a reference. - fn current_buffer(&self) -> &Buffer { - &self.buffers[self.current] - } - - /// Gets the current buffer as a mutable reference. - fn current_buffer_mut(&mut self) -> &mut Buffer { - &mut self.buffers[self.current] - } - - /// Gets the previous buffer as a reference. - fn previous_buffer(&self) -> &Buffer { - &self.buffers[1 - self.current] - } - - /// Gets the previous buffer as a mutable reference. - fn previous_buffer_mut(&mut self) -> &mut Buffer { - &mut self.buffers[1 - self.current] - } - - /// Gets the backend - pub const fn backend(&self) -> &B { - &self.backend - } - - /// Gets the backend as a mutable reference - pub fn backend_mut(&mut self) -> &mut B { - &mut self.backend - } - - /// Obtains a difference between the previous and the current buffer and passes it to the - /// current backend for drawing. - pub fn flush(&mut self) -> io::Result<()> { - let updates = diff_buffers(self.previous_buffer(), self.current_buffer()); - let last_put_command = updates.iter().rfind(|command| command.is_put()); - if let Some(&DrawCommand::Put { x, y, .. }) = last_put_command { - self.last_known_cursor_pos = Position { x, y }; - } - draw(&mut self.backend, updates.into_iter()) - } - - /// Updates the Terminal so that internal buffers match the requested area. - /// - /// Requested area will be saved to remain consistent when rendering. This leads to a full clear - /// of the screen. - pub fn resize(&mut self, screen_size: Size) -> io::Result<()> { - self.last_known_screen_size = screen_size; - Ok(()) - } - - /// Sets the viewport area. - pub fn set_viewport_area(&mut self, area: Rect) { - self.current_buffer_mut().resize(area); - self.previous_buffer_mut().resize(area); - self.viewport_area = area; - } - - /// Queries the backend for size and resizes if it doesn't match the previous size. - pub fn autoresize(&mut self) -> io::Result<()> { - let screen_size = self.size()?; - if screen_size != self.last_known_screen_size { - self.resize(screen_size)?; - } - Ok(()) - } - - /// Draws a single frame to the terminal. - /// - /// Returns a [`CompletedFrame`] if successful, otherwise a [`std::io::Error`]. - /// - /// If the render callback passed to this method can fail, use [`try_draw`] instead. - /// - /// Applications should call `draw` or [`try_draw`] in a loop to continuously render the - /// terminal. These methods are the main entry points for drawing to the terminal. - /// - /// [`try_draw`]: Terminal::try_draw - /// - /// This method will: - /// - /// - autoresize the terminal if necessary - /// - call the render callback, passing it a [`Frame`] reference to render to - /// - flush the current internal state by copying the current buffer to the backend - /// - move the cursor to the last known position if it was set during the rendering closure - /// - /// The render callback should fully render the entire frame when called, including areas that - /// are unchanged from the previous frame. This is because each frame is compared to the - /// previous frame to determine what has changed, and only the changes are written to the - /// terminal. If the render callback does not fully render the frame, the terminal will not be - /// in a consistent state. - pub fn draw(&mut self, render_callback: F) -> io::Result<()> - where - F: FnOnce(&mut Frame), - { - self.try_draw(|frame| { - render_callback(frame); - io::Result::Ok(()) - }) - } - - /// Tries to draw a single frame to the terminal. - /// - /// Returns [`Result::Ok`] containing a [`CompletedFrame`] if successful, otherwise - /// [`Result::Err`] containing the [`std::io::Error`] that caused the failure. - /// - /// This is the equivalent of [`Terminal::draw`] but the render callback is a function or - /// closure that returns a `Result` instead of nothing. - /// - /// Applications should call `try_draw` or [`draw`] in a loop to continuously render the - /// terminal. These methods are the main entry points for drawing to the terminal. - /// - /// [`draw`]: Terminal::draw - /// - /// This method will: - /// - /// - autoresize the terminal if necessary - /// - call the render callback, passing it a [`Frame`] reference to render to - /// - flush the current internal state by copying the current buffer to the backend - /// - move the cursor to the last known position if it was set during the rendering closure - /// - return a [`CompletedFrame`] with the current buffer and the area of the terminal - /// - /// The render callback passed to `try_draw` can return any [`Result`] with an error type that - /// can be converted into an [`std::io::Error`] using the [`Into`] trait. This makes it possible - /// to use the `?` operator to propagate errors that occur during rendering. If the render - /// callback returns an error, the error will be returned from `try_draw` as an - /// [`std::io::Error`] and the terminal will not be updated. - /// - /// The [`CompletedFrame`] returned by this method can be useful for debugging or testing - /// purposes, but it is often not used in regular applicationss. - /// - /// The render callback should fully render the entire frame when called, including areas that - /// are unchanged from the previous frame. This is because each frame is compared to the - /// previous frame to determine what has changed, and only the changes are written to the - /// terminal. If the render function does not fully render the frame, the terminal will not be - /// in a consistent state. - pub fn try_draw(&mut self, render_callback: F) -> io::Result<()> - where - F: FnOnce(&mut Frame) -> Result<(), E>, - E: Into, - { - // Autoresize - otherwise we get glitches if shrinking or potential desync between widgets - // and the terminal (if growing), which may OOB. - self.autoresize()?; - - let mut frame = self.get_frame(); - - render_callback(&mut frame).map_err(Into::into)?; - - // We can't change the cursor position right away because we have to flush the frame to - // stdout first. But we also can't keep the frame around, since it holds a &mut to - // Buffer. Thus, we're taking the important data out of the Frame and dropping it. - let cursor_position = frame.cursor_position; - - // Draw to stdout - self.flush()?; - - match cursor_position { - None => self.hide_cursor()?, - Some(position) => { - self.show_cursor()?; - self.set_cursor_position(position)?; - } - } - - self.swap_buffers(); - - Backend::flush(&mut self.backend)?; - - Ok(()) - } - - /// Hides the cursor. - pub fn hide_cursor(&mut self) -> io::Result<()> { - self.backend.hide_cursor()?; - self.hidden_cursor = true; - Ok(()) - } - - /// Shows the cursor. - pub fn show_cursor(&mut self) -> io::Result<()> { - self.backend.show_cursor()?; - self.hidden_cursor = false; - Ok(()) - } - - /// Gets the current cursor position. - /// - /// This is the position of the cursor after the last draw call. - #[allow(dead_code)] - pub fn get_cursor_position(&mut self) -> io::Result { - self.backend.get_cursor_position() - } - - /// Sets the cursor position. - pub fn set_cursor_position>(&mut self, position: P) -> io::Result<()> { - let position = position.into(); - self.backend.set_cursor_position(position)?; - self.last_known_cursor_pos = position; - Ok(()) - } - - /// Clear the terminal and force a full redraw on the next draw call. - pub fn clear(&mut self) -> io::Result<()> { - if self.viewport_area.is_empty() { - return Ok(()); - } - self.backend - .set_cursor_position(self.viewport_area.as_position())?; - self.backend.clear_region(ClearType::AfterCursor)?; - // Reset the back buffer to make sure the next update will redraw everything. - self.previous_buffer_mut().reset(); - Ok(()) - } - - /// Clears the inactive buffer and swaps it with the current buffer - pub fn swap_buffers(&mut self) { - self.previous_buffer_mut().reset(); - self.current = 1 - self.current; - } - - /// Queries the real size of the backend. - pub fn size(&self) -> io::Result { - self.backend.size() - } -} - -use ratatui::buffer::Cell; -use unicode_width::UnicodeWidthStr; - -#[derive(Debug, IsVariant)] -enum DrawCommand { - Put { x: u16, y: u16, cell: Cell }, - ClearToEnd { x: u16, y: u16, bg: Color }, -} - -fn diff_buffers(a: &Buffer, b: &Buffer) -> Vec { - let previous_buffer = &a.content; - let next_buffer = &b.content; - - let mut updates = vec![]; - let mut last_nonblank_columns = vec![0; a.area.height as usize]; - for y in 0..a.area.height { - let row_start = y as usize * a.area.width as usize; - let row_end = row_start + a.area.width as usize; - let row = &next_buffer[row_start..row_end]; - let bg = row.last().map(|cell| cell.bg).unwrap_or(Color::Reset); - - // Scan the row to find the rightmost column that still matters: any non-space glyph, - // any cell whose bg differs from the row’s trailing bg, or any cell with modifiers. - // Multi-width glyphs extend that region through their full displayed width. - // After that point the rest of the row can be cleared with a single ClearToEnd, a perf win - // versus emitting multiple space Put commands. - let mut last_nonblank_column = 0usize; - let mut column = 0usize; - while column < row.len() { - let cell = &row[column]; - let width = cell.symbol().width(); - if cell.symbol() != " " || cell.bg != bg || cell.modifier != Modifier::empty() { - last_nonblank_column = column + (width.saturating_sub(1)); - } - column += width.max(1); // treat zero-width symbols as width 1 - } - - if last_nonblank_column + 1 < row.len() { - let (x, y) = a.pos_of(row_start + last_nonblank_column + 1); - updates.push(DrawCommand::ClearToEnd { x, y, bg }); - } - - last_nonblank_columns[y as usize] = last_nonblank_column as u16; - } - - // Cells invalidated by drawing/replacing preceding multi-width characters: - let mut invalidated: usize = 0; - // Cells from the current buffer to skip due to preceding multi-width characters taking - // their place (the skipped cells should be blank anyway), or due to per-cell-skipping: - let mut to_skip: usize = 0; - for (i, (current, previous)) in next_buffer.iter().zip(previous_buffer.iter()).enumerate() { - if !current.skip && (current != previous || invalidated > 0) && to_skip == 0 { - let (x, y) = a.pos_of(i); - let row = i / a.area.width as usize; - if x <= last_nonblank_columns[row] { - updates.push(DrawCommand::Put { - x, - y, - cell: next_buffer[i].clone(), - }); - } - } - - to_skip = current.symbol().width().saturating_sub(1); - - let affected_width = std::cmp::max(current.symbol().width(), previous.symbol().width()); - invalidated = std::cmp::max(affected_width, invalidated).saturating_sub(1); - } - updates -} - -fn draw(writer: &mut impl Write, commands: I) -> io::Result<()> -where - I: Iterator, -{ - let mut fg = Color::Reset; - let mut bg = Color::Reset; - let mut modifier = Modifier::empty(); - let mut last_pos: Option = None; - for command in commands { - let (x, y) = match command { - DrawCommand::Put { x, y, .. } => (x, y), - DrawCommand::ClearToEnd { x, y, .. } => (x, y), - }; - // Move the cursor if the previous location was not (x - 1, y) - if !matches!(last_pos, Some(p) if x == p.x + 1 && y == p.y) { - queue!(writer, MoveTo(x, y))?; - } - last_pos = Some(Position { x, y }); - match command { - DrawCommand::Put { cell, .. } => { - if cell.modifier != modifier { - let diff = ModifierDiff { - from: modifier, - to: cell.modifier, - }; - diff.queue(writer)?; - modifier = cell.modifier; - } - if cell.fg != fg || cell.bg != bg { - queue!( - writer, - SetColors(Colors::new(cell.fg.into(), cell.bg.into())) - )?; - fg = cell.fg; - bg = cell.bg; - } - - queue!(writer, Print(cell.symbol()))?; - } - DrawCommand::ClearToEnd { bg: clear_bg, .. } => { - queue!(writer, SetAttribute(crossterm::style::Attribute::Reset))?; - modifier = Modifier::empty(); - queue!(writer, SetBackgroundColor(clear_bg.into()))?; - bg = clear_bg; - queue!(writer, Clear(crossterm::terminal::ClearType::UntilNewLine))?; - } - } - } - - queue!( - writer, - SetForegroundColor(crossterm::style::Color::Reset), - SetBackgroundColor(crossterm::style::Color::Reset), - SetAttribute(crossterm::style::Attribute::Reset), - )?; - - Ok(()) -} - -/// The `ModifierDiff` struct is used to calculate the difference between two `Modifier` -/// values. This is useful when updating the terminal display, as it allows for more -/// efficient updates by only sending the necessary changes. -struct ModifierDiff { - pub from: Modifier, - pub to: Modifier, -} - -impl ModifierDiff { - fn queue(self, w: &mut W) -> io::Result<()> { - use crossterm::style::Attribute as CAttribute; - let removed = self.from - self.to; - if removed.contains(Modifier::REVERSED) { - queue!(w, SetAttribute(CAttribute::NoReverse))?; - } - if removed.contains(Modifier::BOLD) { - queue!(w, SetAttribute(CAttribute::NormalIntensity))?; - if self.to.contains(Modifier::DIM) { - queue!(w, SetAttribute(CAttribute::Dim))?; - } - } - if removed.contains(Modifier::ITALIC) { - queue!(w, SetAttribute(CAttribute::NoItalic))?; - } - if removed.contains(Modifier::UNDERLINED) { - queue!(w, SetAttribute(CAttribute::NoUnderline))?; - } - if removed.contains(Modifier::DIM) { - queue!(w, SetAttribute(CAttribute::NormalIntensity))?; - } - if removed.contains(Modifier::CROSSED_OUT) { - queue!(w, SetAttribute(CAttribute::NotCrossedOut))?; - } - if removed.contains(Modifier::SLOW_BLINK) || removed.contains(Modifier::RAPID_BLINK) { - queue!(w, SetAttribute(CAttribute::NoBlink))?; - } - - let added = self.to - self.from; - if added.contains(Modifier::REVERSED) { - queue!(w, SetAttribute(CAttribute::Reverse))?; - } - if added.contains(Modifier::BOLD) { - queue!(w, SetAttribute(CAttribute::Bold))?; - } - if added.contains(Modifier::ITALIC) { - queue!(w, SetAttribute(CAttribute::Italic))?; - } - if added.contains(Modifier::UNDERLINED) { - queue!(w, SetAttribute(CAttribute::Underlined))?; - } - if added.contains(Modifier::DIM) { - queue!(w, SetAttribute(CAttribute::Dim))?; - } - if added.contains(Modifier::CROSSED_OUT) { - queue!(w, SetAttribute(CAttribute::CrossedOut))?; - } - if added.contains(Modifier::SLOW_BLINK) { - queue!(w, SetAttribute(CAttribute::SlowBlink))?; - } - if added.contains(Modifier::RAPID_BLINK) { - queue!(w, SetAttribute(CAttribute::RapidBlink))?; - } - - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - use ratatui::layout::Rect; - use ratatui::style::Style; - - #[test] - fn diff_buffers_does_not_emit_clear_to_end_for_full_width_row() { - let area = Rect::new(0, 0, 3, 2); - let previous = Buffer::empty(area); - let mut next = Buffer::empty(area); - - next.cell_mut((2, 0)) - .expect("cell should exist") - .set_symbol("X"); - - let commands = diff_buffers(&previous, &next); - - let clear_count = commands - .iter() - .filter(|command| matches!(command, DrawCommand::ClearToEnd { y, .. } if *y == 0)) - .count(); - assert_eq!( - 0, clear_count, - "expected diff_buffers not to emit ClearToEnd; commands: {commands:?}", - ); - assert!( - commands - .iter() - .any(|command| matches!(command, DrawCommand::Put { x: 2, y: 0, .. })), - "expected diff_buffers to update the final cell; commands: {commands:?}", - ); - } - - #[test] - fn diff_buffers_clear_to_end_starts_after_wide_char() { - let area = Rect::new(0, 0, 10, 1); - let mut previous = Buffer::empty(area); - let mut next = Buffer::empty(area); - - previous.set_string(0, 0, "中文", Style::default()); - next.set_string(0, 0, "中", Style::default()); - - let commands = diff_buffers(&previous, &next); - assert!( - commands - .iter() - .any(|command| matches!(command, DrawCommand::ClearToEnd { x: 2, y: 0, .. })), - "expected clear-to-end to start after the remaining wide char; commands: {commands:?}" - ); - } -} diff --git a/codex-rs/tui2/src/diff_render.rs b/codex-rs/tui2/src/diff_render.rs deleted file mode 100644 index b92f24349a8..00000000000 --- a/codex-rs/tui2/src/diff_render.rs +++ /dev/null @@ -1,708 +0,0 @@ -use diffy::Hunk; -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -use ratatui::style::Color; -use ratatui::style::Modifier; -use ratatui::style::Style; -use ratatui::style::Stylize; -use ratatui::text::Line as RtLine; -use ratatui::text::Span as RtSpan; -use ratatui::widgets::Paragraph; -use std::collections::HashMap; -use std::path::Path; -use std::path::PathBuf; - -use crate::exec_command::relativize_to_home; -use crate::render::Insets; -use crate::render::line_utils::prefix_lines; -use crate::render::renderable::ColumnRenderable; -use crate::render::renderable::InsetRenderable; -use crate::render::renderable::Renderable; -use codex_core::git_info::get_git_repo_root; -use codex_core::protocol::FileChange; - -// Internal representation for diff line rendering -enum DiffLineType { - Insert, - Delete, - Context, -} - -pub struct DiffSummary { - changes: HashMap, - cwd: PathBuf, -} - -impl DiffSummary { - pub fn new(changes: HashMap, cwd: PathBuf) -> Self { - Self { changes, cwd } - } -} - -impl Renderable for FileChange { - fn render(&self, area: Rect, buf: &mut Buffer) { - let mut lines = vec![]; - render_change(self, &mut lines, area.width as usize); - Paragraph::new(lines).render(area, buf); - } - - fn desired_height(&self, width: u16) -> u16 { - let mut lines = vec![]; - render_change(self, &mut lines, width as usize); - lines.len() as u16 - } -} - -impl From for Box { - fn from(val: DiffSummary) -> Self { - let mut rows: Vec> = vec![]; - - for (i, row) in collect_rows(&val.changes).into_iter().enumerate() { - if i > 0 { - rows.push(Box::new(RtLine::from(""))); - } - let mut path = RtLine::from(display_path_for(&row.path, &val.cwd)); - path.push_span(" "); - path.extend(render_line_count_summary(row.added, row.removed)); - rows.push(Box::new(path)); - rows.push(Box::new(RtLine::from(""))); - rows.push(Box::new(InsetRenderable::new( - Box::new(row.change) as Box, - Insets::tlbr(0, 2, 0, 0), - ))); - } - - Box::new(ColumnRenderable::with(rows)) - } -} - -pub(crate) fn create_diff_summary( - changes: &HashMap, - cwd: &Path, - wrap_cols: usize, -) -> Vec> { - let rows = collect_rows(changes); - render_changes_block(rows, wrap_cols, cwd) -} - -// Shared row for per-file presentation -#[derive(Clone)] -struct Row { - #[allow(dead_code)] - path: PathBuf, - move_path: Option, - added: usize, - removed: usize, - change: FileChange, -} - -fn collect_rows(changes: &HashMap) -> Vec { - let mut rows: Vec = Vec::new(); - for (path, change) in changes.iter() { - let (added, removed) = match change { - FileChange::Add { content } => (content.lines().count(), 0), - FileChange::Delete { content } => (0, content.lines().count()), - FileChange::Update { unified_diff, .. } => calculate_add_remove_from_diff(unified_diff), - }; - let move_path = match change { - FileChange::Update { - move_path: Some(new), - .. - } => Some(new.clone()), - _ => None, - }; - rows.push(Row { - path: path.clone(), - move_path, - added, - removed, - change: change.clone(), - }); - } - rows.sort_by_key(|r| r.path.clone()); - rows -} - -fn render_line_count_summary(added: usize, removed: usize) -> Vec> { - let mut spans = Vec::new(); - spans.push("(".into()); - spans.push(format!("+{added}").green()); - spans.push(" ".into()); - spans.push(format!("-{removed}").red()); - spans.push(")".into()); - spans -} - -fn render_changes_block(rows: Vec, wrap_cols: usize, cwd: &Path) -> Vec> { - let mut out: Vec> = Vec::new(); - - let render_path = |row: &Row| -> Vec> { - let mut spans = Vec::new(); - spans.push(display_path_for(&row.path, cwd).into()); - if let Some(move_path) = &row.move_path { - spans.push(format!(" → {}", display_path_for(move_path, cwd)).into()); - } - spans - }; - - // Header - let total_added: usize = rows.iter().map(|r| r.added).sum(); - let total_removed: usize = rows.iter().map(|r| r.removed).sum(); - let file_count = rows.len(); - let noun = if file_count == 1 { "file" } else { "files" }; - let mut header_spans: Vec> = vec!["• ".dim()]; - if let [row] = &rows[..] { - let verb = match &row.change { - FileChange::Add { .. } => "Added", - FileChange::Delete { .. } => "Deleted", - _ => "Edited", - }; - header_spans.push(verb.bold()); - header_spans.push(" ".into()); - header_spans.extend(render_path(row)); - header_spans.push(" ".into()); - header_spans.extend(render_line_count_summary(row.added, row.removed)); - } else { - header_spans.push("Edited".bold()); - header_spans.push(format!(" {file_count} {noun} ").into()); - header_spans.extend(render_line_count_summary(total_added, total_removed)); - } - out.push(RtLine::from(header_spans)); - - for (idx, r) in rows.into_iter().enumerate() { - // Insert a blank separator between file chunks (except before the first) - if idx > 0 { - out.push("".into()); - } - // File header line (skip when single-file header already shows the name) - let skip_file_header = file_count == 1; - if !skip_file_header { - let mut header: Vec> = Vec::new(); - header.push(" └ ".dim()); - header.extend(render_path(&r)); - header.push(" ".into()); - header.extend(render_line_count_summary(r.added, r.removed)); - out.push(RtLine::from(header)); - } - - let mut lines = vec![]; - render_change(&r.change, &mut lines, wrap_cols - 4); - out.extend(prefix_lines(lines, " ".into(), " ".into())); - } - - out -} - -fn render_change(change: &FileChange, out: &mut Vec>, width: usize) { - match change { - FileChange::Add { content } => { - let line_number_width = line_number_width(content.lines().count()); - for (i, raw) in content.lines().enumerate() { - out.extend(push_wrapped_diff_line( - i + 1, - DiffLineType::Insert, - raw, - width, - line_number_width, - )); - } - } - FileChange::Delete { content } => { - let line_number_width = line_number_width(content.lines().count()); - for (i, raw) in content.lines().enumerate() { - out.extend(push_wrapped_diff_line( - i + 1, - DiffLineType::Delete, - raw, - width, - line_number_width, - )); - } - } - FileChange::Update { unified_diff, .. } => { - if let Ok(patch) = diffy::Patch::from_str(unified_diff) { - let mut max_line_number = 0; - for h in patch.hunks() { - let mut old_ln = h.old_range().start(); - let mut new_ln = h.new_range().start(); - for l in h.lines() { - match l { - diffy::Line::Insert(_) => { - max_line_number = max_line_number.max(new_ln); - new_ln += 1; - } - diffy::Line::Delete(_) => { - max_line_number = max_line_number.max(old_ln); - old_ln += 1; - } - diffy::Line::Context(_) => { - max_line_number = max_line_number.max(new_ln); - old_ln += 1; - new_ln += 1; - } - } - } - } - let line_number_width = line_number_width(max_line_number); - let mut is_first_hunk = true; - for h in patch.hunks() { - if !is_first_hunk { - let spacer = format!("{:width$} ", "", width = line_number_width.max(1)); - let spacer_span = RtSpan::styled(spacer, style_gutter()); - out.push(RtLine::from(vec![spacer_span, "⋮".dim()])); - } - is_first_hunk = false; - - let mut old_ln = h.old_range().start(); - let mut new_ln = h.new_range().start(); - for l in h.lines() { - match l { - diffy::Line::Insert(text) => { - let s = text.trim_end_matches('\n'); - out.extend(push_wrapped_diff_line( - new_ln, - DiffLineType::Insert, - s, - width, - line_number_width, - )); - new_ln += 1; - } - diffy::Line::Delete(text) => { - let s = text.trim_end_matches('\n'); - out.extend(push_wrapped_diff_line( - old_ln, - DiffLineType::Delete, - s, - width, - line_number_width, - )); - old_ln += 1; - } - diffy::Line::Context(text) => { - let s = text.trim_end_matches('\n'); - out.extend(push_wrapped_diff_line( - new_ln, - DiffLineType::Context, - s, - width, - line_number_width, - )); - old_ln += 1; - new_ln += 1; - } - } - } - } - } - } - } -} - -/// Format a path for display relative to the current working directory when -/// possible, keeping output stable in jj/no-`.git` workspaces (e.g. image -/// tool calls should show `example.png` instead of an absolute path). -pub(crate) fn display_path_for(path: &Path, cwd: &Path) -> String { - if path.is_relative() { - return path.display().to_string(); - } - - if let Ok(stripped) = path.strip_prefix(cwd) { - return stripped.display().to_string(); - } - - // Prefer a stable, user-local relative path when the file is under the current working - // directory. This keeps output deterministic in jj-only repos (no `.git`) and matches user - // expectations for "files in this project". - if let Some(rel) = pathdiff::diff_paths(path, cwd) - && !rel - .components() - .any(|c| matches!(c, std::path::Component::ParentDir)) - { - return rel.display().to_string(); - } - - let path_in_same_repo = match (get_git_repo_root(cwd), get_git_repo_root(path)) { - (Some(cwd_repo), Some(path_repo)) => cwd_repo == path_repo, - _ => false, - }; - let chosen = if path_in_same_repo { - pathdiff::diff_paths(path, cwd).unwrap_or_else(|| path.to_path_buf()) - } else { - relativize_to_home(path) - .map(|p| PathBuf::from_iter([Path::new("~"), p.as_path()])) - .unwrap_or_else(|| path.to_path_buf()) - }; - chosen.display().to_string() -} - -fn calculate_add_remove_from_diff(diff: &str) -> (usize, usize) { - if let Ok(patch) = diffy::Patch::from_str(diff) { - patch - .hunks() - .iter() - .flat_map(Hunk::lines) - .fold((0, 0), |(a, d), l| match l { - diffy::Line::Insert(_) => (a + 1, d), - diffy::Line::Delete(_) => (a, d + 1), - diffy::Line::Context(_) => (a, d), - }) - } else { - // For unparsable diffs, return 0 for both counts. - (0, 0) - } -} - -fn push_wrapped_diff_line( - line_number: usize, - kind: DiffLineType, - text: &str, - width: usize, - line_number_width: usize, -) -> Vec> { - let ln_str = line_number.to_string(); - let mut remaining_text: &str = text; - - // Reserve a fixed number of spaces (equal to the widest line number plus a - // trailing spacer) so the sign column stays aligned across the diff block. - let gutter_width = line_number_width.max(1); - let prefix_cols = gutter_width + 1; - - let mut first = true; - let (sign_char, line_style) = match kind { - DiffLineType::Insert => ('+', style_add()), - DiffLineType::Delete => ('-', style_del()), - DiffLineType::Context => (' ', style_context()), - }; - let mut lines: Vec> = Vec::new(); - - loop { - // Fit the content for the current terminal row: - // compute how many columns are available after the prefix, then split - // at a UTF-8 character boundary so this row's chunk fits exactly. - let available_content_cols = width.saturating_sub(prefix_cols + 1).max(1); - let split_at_byte_index = remaining_text - .char_indices() - .nth(available_content_cols) - .map(|(i, _)| i) - .unwrap_or_else(|| remaining_text.len()); - let (chunk, rest) = remaining_text.split_at(split_at_byte_index); - remaining_text = rest; - - if first { - // Build gutter (right-aligned line number plus spacer) as a dimmed span - let gutter = format!("{ln_str:>gutter_width$} "); - // Content with a sign ('+'/'-'/' ') styled per diff kind - let content = format!("{sign_char}{chunk}"); - lines.push(RtLine::from(vec![ - RtSpan::styled(gutter, style_gutter()), - RtSpan::styled(content, line_style), - ])); - first = false; - } else { - // Continuation lines keep a space for the sign column so content aligns - let gutter = format!("{:gutter_width$} ", ""); - lines.push(RtLine::from(vec![ - RtSpan::styled(gutter, style_gutter()), - RtSpan::styled(chunk.to_string(), line_style), - ])); - } - if remaining_text.is_empty() { - break; - } - } - lines -} - -fn line_number_width(max_line_number: usize) -> usize { - if max_line_number == 0 { - 1 - } else { - max_line_number.to_string().len() - } -} - -fn style_gutter() -> Style { - Style::default().add_modifier(Modifier::DIM) -} - -fn style_context() -> Style { - Style::default() -} - -fn style_add() -> Style { - Style::default().fg(Color::Green) -} - -fn style_del() -> Style { - Style::default().fg(Color::Red) -} - -#[cfg(test)] -mod tests { - use super::*; - use insta::assert_snapshot; - use pretty_assertions::assert_eq; - use ratatui::Terminal; - use ratatui::backend::TestBackend; - use ratatui::text::Text; - use ratatui::widgets::Paragraph; - use ratatui::widgets::WidgetRef; - use ratatui::widgets::Wrap; - fn diff_summary_for_tests(changes: &HashMap) -> Vec> { - create_diff_summary(changes, &PathBuf::from("/"), 80) - } - - fn snapshot_lines(name: &str, lines: Vec>, width: u16, height: u16) { - let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("terminal"); - terminal - .draw(|f| { - Paragraph::new(Text::from(lines)) - .wrap(Wrap { trim: false }) - .render_ref(f.area(), f.buffer_mut()) - }) - .expect("draw"); - assert_snapshot!(name, terminal.backend()); - } - - fn snapshot_lines_text(name: &str, lines: &[RtLine<'static>]) { - // Convert Lines to plain text rows and trim trailing spaces so it's - // easier to validate indentation visually in snapshots. - let text = lines - .iter() - .map(|l| { - l.spans - .iter() - .map(|s| s.content.as_ref()) - .collect::() - }) - .map(|s| s.trim_end().to_string()) - .collect::>() - .join("\n"); - assert_snapshot!(name, text); - } - - #[test] - fn display_path_prefers_cwd_without_git_repo() { - let cwd = if cfg!(windows) { - PathBuf::from(r"C:\workspace\codex") - } else { - PathBuf::from("/workspace/codex") - }; - let path = cwd.join("tui").join("example.png"); - - let rendered = display_path_for(&path, &cwd); - - assert_eq!( - rendered, - PathBuf::from("tui") - .join("example.png") - .display() - .to_string() - ); - } - - #[test] - fn ui_snapshot_wrap_behavior_insert() { - // Narrow width to force wrapping within our diff line rendering - let long_line = "this is a very long line that should wrap across multiple terminal columns and continue"; - - // Call the wrapping function directly so we can precisely control the width - let lines = - push_wrapped_diff_line(1, DiffLineType::Insert, long_line, 80, line_number_width(1)); - - // Render into a small terminal to capture the visual layout - snapshot_lines("wrap_behavior_insert", lines, 90, 8); - } - - #[test] - fn ui_snapshot_apply_update_block() { - let mut changes: HashMap = HashMap::new(); - let original = "line one\nline two\nline three\n"; - let modified = "line one\nline two changed\nline three\n"; - let patch = diffy::create_patch(original, modified).to_string(); - - changes.insert( - PathBuf::from("example.txt"), - FileChange::Update { - unified_diff: patch, - move_path: None, - }, - ); - - let lines = diff_summary_for_tests(&changes); - - snapshot_lines("apply_update_block", lines, 80, 12); - } - - #[test] - fn ui_snapshot_apply_update_with_rename_block() { - let mut changes: HashMap = HashMap::new(); - let original = "A\nB\nC\n"; - let modified = "A\nB changed\nC\n"; - let patch = diffy::create_patch(original, modified).to_string(); - - changes.insert( - PathBuf::from("old_name.rs"), - FileChange::Update { - unified_diff: patch, - move_path: Some(PathBuf::from("new_name.rs")), - }, - ); - - let lines = diff_summary_for_tests(&changes); - - snapshot_lines("apply_update_with_rename_block", lines, 80, 12); - } - - #[test] - fn ui_snapshot_apply_multiple_files_block() { - // Two files: one update and one add, to exercise combined header and per-file rows - let mut changes: HashMap = HashMap::new(); - - // File a.txt: single-line replacement (one delete, one insert) - let patch_a = diffy::create_patch("one\n", "one changed\n").to_string(); - changes.insert( - PathBuf::from("a.txt"), - FileChange::Update { - unified_diff: patch_a, - move_path: None, - }, - ); - - // File b.txt: newly added with one line - changes.insert( - PathBuf::from("b.txt"), - FileChange::Add { - content: "new\n".to_string(), - }, - ); - - let lines = diff_summary_for_tests(&changes); - - snapshot_lines("apply_multiple_files_block", lines, 80, 14); - } - - #[test] - fn ui_snapshot_apply_add_block() { - let mut changes: HashMap = HashMap::new(); - changes.insert( - PathBuf::from("new_file.txt"), - FileChange::Add { - content: "alpha\nbeta\n".to_string(), - }, - ); - - let lines = diff_summary_for_tests(&changes); - - snapshot_lines("apply_add_block", lines, 80, 10); - } - - #[test] - fn ui_snapshot_apply_delete_block() { - let mut changes: HashMap = HashMap::new(); - changes.insert( - PathBuf::from("tmp_delete_example.txt"), - FileChange::Delete { - content: "first\nsecond\nthird\n".to_string(), - }, - ); - - let lines = diff_summary_for_tests(&changes); - snapshot_lines("apply_delete_block", lines, 80, 12); - } - - #[test] - fn ui_snapshot_apply_update_block_wraps_long_lines() { - // Create a patch with a long modified line to force wrapping - let original = "line 1\nshort\nline 3\n"; - let modified = "line 1\nshort this_is_a_very_long_modified_line_that_should_wrap_across_multiple_terminal_columns_and_continue_even_further_beyond_eighty_columns_to_force_multiple_wraps\nline 3\n"; - let patch = diffy::create_patch(original, modified).to_string(); - - let mut changes: HashMap = HashMap::new(); - changes.insert( - PathBuf::from("long_example.txt"), - FileChange::Update { - unified_diff: patch, - move_path: None, - }, - ); - - let lines = create_diff_summary(&changes, &PathBuf::from("/"), 72); - - // Render with backend width wider than wrap width to avoid Paragraph auto-wrap. - snapshot_lines("apply_update_block_wraps_long_lines", lines, 80, 12); - } - - #[test] - fn ui_snapshot_apply_update_block_wraps_long_lines_text() { - // This mirrors the desired layout example: sign only on first inserted line, - // subsequent wrapped pieces start aligned under the line number gutter. - let original = "1\n2\n3\n4\n"; - let modified = "1\nadded long line which wraps and_if_there_is_a_long_token_it_will_be_broken\n3\n4 context line which also wraps across\n"; - let patch = diffy::create_patch(original, modified).to_string(); - - let mut changes: HashMap = HashMap::new(); - changes.insert( - PathBuf::from("wrap_demo.txt"), - FileChange::Update { - unified_diff: patch, - move_path: None, - }, - ); - - let lines = create_diff_summary(&changes, &PathBuf::from("/"), 28); - snapshot_lines_text("apply_update_block_wraps_long_lines_text", &lines); - } - - #[test] - fn ui_snapshot_apply_update_block_line_numbers_three_digits_text() { - let original = (1..=110).map(|i| format!("line {i}\n")).collect::(); - let modified = (1..=110) - .map(|i| { - if i == 100 { - format!("line {i} changed\n") - } else { - format!("line {i}\n") - } - }) - .collect::(); - let patch = diffy::create_patch(&original, &modified).to_string(); - - let mut changes: HashMap = HashMap::new(); - changes.insert( - PathBuf::from("hundreds.txt"), - FileChange::Update { - unified_diff: patch, - move_path: None, - }, - ); - - let lines = create_diff_summary(&changes, &PathBuf::from("/"), 80); - snapshot_lines_text("apply_update_block_line_numbers_three_digits_text", &lines); - } - - #[test] - fn ui_snapshot_apply_update_block_relativizes_path() { - let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")); - let abs_old = cwd.join("abs_old.rs"); - let abs_new = cwd.join("abs_new.rs"); - - let original = "X\nY\n"; - let modified = "X changed\nY\n"; - let patch = diffy::create_patch(original, modified).to_string(); - - let mut changes: HashMap = HashMap::new(); - changes.insert( - abs_old, - FileChange::Update { - unified_diff: patch, - move_path: Some(abs_new), - }, - ); - - let lines = create_diff_summary(&changes, &cwd, 80); - - snapshot_lines("apply_update_block_relativizes_path", lines, 80, 10); - } -} diff --git a/codex-rs/tui2/src/exec_cell/mod.rs b/codex-rs/tui2/src/exec_cell/mod.rs deleted file mode 100644 index 906091113e9..00000000000 --- a/codex-rs/tui2/src/exec_cell/mod.rs +++ /dev/null @@ -1,12 +0,0 @@ -mod model; -mod render; - -pub(crate) use model::CommandOutput; -#[cfg(test)] -pub(crate) use model::ExecCall; -pub(crate) use model::ExecCell; -pub(crate) use render::OutputLinesParams; -pub(crate) use render::TOOL_CALL_MAX_LINES; -pub(crate) use render::new_active_exec_command; -pub(crate) use render::output_lines; -pub(crate) use render::spinner; diff --git a/codex-rs/tui2/src/exec_cell/model.rs b/codex-rs/tui2/src/exec_cell/model.rs deleted file mode 100644 index 21799c6199b..00000000000 --- a/codex-rs/tui2/src/exec_cell/model.rs +++ /dev/null @@ -1,162 +0,0 @@ -use std::time::Duration; -use std::time::Instant; - -use codex_core::protocol::ExecCommandSource; -use codex_protocol::parse_command::ParsedCommand; - -#[derive(Clone, Debug, Default)] -pub(crate) struct CommandOutput { - pub(crate) exit_code: i32, - /// The aggregated stderr + stdout interleaved. - pub(crate) aggregated_output: String, - /// The formatted output of the command, as seen by the model. - pub(crate) formatted_output: String, -} - -#[derive(Debug, Clone)] -pub(crate) struct ExecCall { - pub(crate) call_id: String, - pub(crate) command: Vec, - pub(crate) parsed: Vec, - pub(crate) output: Option, - pub(crate) source: ExecCommandSource, - pub(crate) start_time: Option, - pub(crate) duration: Option, - pub(crate) interaction_input: Option, -} - -#[derive(Debug)] -pub(crate) struct ExecCell { - pub(crate) calls: Vec, - animations_enabled: bool, -} - -impl ExecCell { - pub(crate) fn new(call: ExecCall, animations_enabled: bool) -> Self { - Self { - calls: vec![call], - animations_enabled, - } - } - - pub(crate) fn with_added_call( - &self, - call_id: String, - command: Vec, - parsed: Vec, - source: ExecCommandSource, - interaction_input: Option, - ) -> Option { - let call = ExecCall { - call_id, - command, - parsed, - output: None, - source, - start_time: Some(Instant::now()), - duration: None, - interaction_input, - }; - if self.is_exploring_cell() && Self::is_exploring_call(&call) { - Some(Self { - calls: [self.calls.clone(), vec![call]].concat(), - animations_enabled: self.animations_enabled, - }) - } else { - None - } - } - - pub(crate) fn complete_call( - &mut self, - call_id: &str, - output: CommandOutput, - duration: Duration, - ) { - if let Some(call) = self.calls.iter_mut().rev().find(|c| c.call_id == call_id) { - call.output = Some(output); - call.duration = Some(duration); - call.start_time = None; - } - } - - pub(crate) fn should_flush(&self) -> bool { - !self.is_exploring_cell() && self.calls.iter().all(|c| c.output.is_some()) - } - - pub(crate) fn mark_failed(&mut self) { - for call in self.calls.iter_mut() { - if call.output.is_none() { - let elapsed = call - .start_time - .map(|st| st.elapsed()) - .unwrap_or_else(|| Duration::from_millis(0)); - call.start_time = None; - call.duration = Some(elapsed); - call.output = Some(CommandOutput { - exit_code: 1, - formatted_output: String::new(), - aggregated_output: String::new(), - }); - } - } - } - - pub(crate) fn is_exploring_cell(&self) -> bool { - self.calls.iter().all(Self::is_exploring_call) - } - - pub(crate) fn is_active(&self) -> bool { - self.calls.iter().any(|c| c.output.is_none()) - } - - pub(crate) fn active_start_time(&self) -> Option { - self.calls - .iter() - .find(|c| c.output.is_none()) - .and_then(|c| c.start_time) - } - - pub(crate) fn animations_enabled(&self) -> bool { - self.animations_enabled - } - - pub(crate) fn iter_calls(&self) -> impl Iterator { - self.calls.iter() - } - - pub(crate) fn append_output(&mut self, call_id: &str, chunk: &str) -> bool { - if chunk.is_empty() { - return false; - } - let Some(call) = self.calls.iter_mut().rev().find(|c| c.call_id == call_id) else { - return false; - }; - let output = call.output.get_or_insert_with(CommandOutput::default); - output.aggregated_output.push_str(chunk); - true - } - - pub(super) fn is_exploring_call(call: &ExecCall) -> bool { - !matches!(call.source, ExecCommandSource::UserShell) - && !call.parsed.is_empty() - && call.parsed.iter().all(|p| { - matches!( - p, - ParsedCommand::Read { .. } - | ParsedCommand::ListFiles { .. } - | ParsedCommand::Search { .. } - ) - }) - } -} - -impl ExecCall { - pub(crate) fn is_user_shell_command(&self) -> bool { - matches!(self.source, ExecCommandSource::UserShell) - } - - pub(crate) fn is_unified_exec_interaction(&self) -> bool { - matches!(self.source, ExecCommandSource::UnifiedExecInteraction) - } -} diff --git a/codex-rs/tui2/src/exec_cell/render.rs b/codex-rs/tui2/src/exec_cell/render.rs deleted file mode 100644 index 6517bcf470a..00000000000 --- a/codex-rs/tui2/src/exec_cell/render.rs +++ /dev/null @@ -1,705 +0,0 @@ -use std::time::Instant; - -use super::model::CommandOutput; -use super::model::ExecCall; -use super::model::ExecCell; -use crate::exec_command::strip_bash_lc_and_escape; -use crate::history_cell::HistoryCell; -use crate::render::highlight::highlight_bash_to_lines; -use crate::render::line_utils::prefix_lines; -use crate::render::line_utils::push_owned_lines; -use crate::shimmer::shimmer_spans; -use crate::wrapping::RtOptions; -use crate::wrapping::word_wrap_line; -use crate::wrapping::word_wrap_lines; -use codex_ansi_escape::ansi_escape_line; -use codex_common::elapsed::format_duration; -use codex_core::bash::extract_bash_command; -use codex_core::protocol::ExecCommandSource; -use codex_protocol::parse_command::ParsedCommand; -use itertools::Itertools; -use ratatui::prelude::*; -use ratatui::style::Modifier; -use ratatui::style::Stylize; -use textwrap::WordSplitter; -use unicode_width::UnicodeWidthStr; - -pub(crate) const TOOL_CALL_MAX_LINES: usize = 5; -const USER_SHELL_TOOL_CALL_MAX_LINES: usize = 50; -const MAX_INTERACTION_PREVIEW_CHARS: usize = 80; - -pub(crate) struct OutputLinesParams { - pub(crate) line_limit: usize, - pub(crate) only_err: bool, - pub(crate) include_angle_pipe: bool, - pub(crate) include_prefix: bool, -} - -pub(crate) fn new_active_exec_command( - call_id: String, - command: Vec, - parsed: Vec, - source: ExecCommandSource, - interaction_input: Option, - animations_enabled: bool, -) -> ExecCell { - ExecCell::new( - ExecCall { - call_id, - command, - parsed, - output: None, - source, - start_time: Some(Instant::now()), - duration: None, - interaction_input, - }, - animations_enabled, - ) -} - -fn format_unified_exec_interaction(command: &[String], input: Option<&str>) -> String { - let command_display = if let Some((_, script)) = extract_bash_command(command) { - script.to_string() - } else { - command.join(" ") - }; - match input { - Some(data) if !data.is_empty() => { - let preview = summarize_interaction_input(data); - format!("Interacted with `{command_display}`, sent `{preview}`") - } - _ => format!("Waited for `{command_display}`"), - } -} - -fn summarize_interaction_input(input: &str) -> String { - let single_line = input.replace('\n', "\\n"); - let sanitized = single_line.replace('`', "\\`"); - if sanitized.chars().count() <= MAX_INTERACTION_PREVIEW_CHARS { - return sanitized; - } - - let mut preview = String::new(); - for ch in sanitized.chars().take(MAX_INTERACTION_PREVIEW_CHARS) { - preview.push(ch); - } - preview.push_str("..."); - preview -} - -#[derive(Clone)] -pub(crate) struct OutputLines { - pub(crate) lines: Vec>, - pub(crate) omitted: Option, -} - -pub(crate) fn output_lines( - output: Option<&CommandOutput>, - params: OutputLinesParams, -) -> OutputLines { - let OutputLinesParams { - line_limit, - only_err, - include_angle_pipe, - include_prefix, - } = params; - let CommandOutput { - aggregated_output, .. - } = match output { - Some(output) if only_err && output.exit_code == 0 => { - return OutputLines { - lines: Vec::new(), - omitted: None, - }; - } - Some(output) => output, - None => { - return OutputLines { - lines: Vec::new(), - omitted: None, - }; - } - }; - - let src = aggregated_output; - let lines: Vec<&str> = src.lines().collect(); - let total = lines.len(); - let mut out: Vec> = Vec::new(); - - let head_end = total.min(line_limit); - for (i, raw) in lines[..head_end].iter().enumerate() { - let mut line = ansi_escape_line(raw); - let prefix = if !include_prefix { - "" - } else if i == 0 && include_angle_pipe { - " └ " - } else { - " " - }; - line.spans.insert(0, prefix.into()); - line.spans.iter_mut().for_each(|span| { - span.style = span.style.add_modifier(Modifier::DIM); - }); - out.push(line); - } - - let show_ellipsis = total > 2 * line_limit; - let omitted = if show_ellipsis { - Some(total - 2 * line_limit) - } else { - None - }; - if show_ellipsis { - let omitted = total - 2 * line_limit; - out.push(format!("… +{omitted} lines").into()); - } - - let tail_start = if show_ellipsis { - total - line_limit - } else { - head_end - }; - for raw in lines[tail_start..].iter() { - let mut line = ansi_escape_line(raw); - if include_prefix { - line.spans.insert(0, " ".into()); - } - line.spans.iter_mut().for_each(|span| { - span.style = span.style.add_modifier(Modifier::DIM); - }); - out.push(line); - } - - OutputLines { - lines: out, - omitted, - } -} - -pub(crate) fn spinner(start_time: Option, animations_enabled: bool) -> Span<'static> { - if !animations_enabled { - return "•".dim(); - } - let elapsed = start_time.map(|st| st.elapsed()).unwrap_or_default(); - if supports_color::on_cached(supports_color::Stream::Stdout) - .map(|level| level.has_16m) - .unwrap_or(false) - { - shimmer_spans("•")[0].clone() - } else { - let blink_on = (elapsed.as_millis() / 600).is_multiple_of(2); - if blink_on { "•".into() } else { "◦".dim() } - } -} - -impl HistoryCell for ExecCell { - fn display_lines(&self, width: u16) -> Vec> { - if self.is_exploring_cell() { - self.exploring_display_lines(width) - } else { - self.command_display_lines(width) - } - } - - fn desired_transcript_height(&self, width: u16) -> u16 { - self.transcript_lines(width).len() as u16 - } - - fn transcript_lines(&self, width: u16) -> Vec> { - let mut lines: Vec> = vec![]; - for (i, call) in self.iter_calls().enumerate() { - if i > 0 { - lines.push("".into()); - } - let script = strip_bash_lc_and_escape(&call.command); - let highlighted_script = highlight_bash_to_lines(&script); - let cmd_display = word_wrap_lines( - &highlighted_script, - RtOptions::new(width as usize) - .initial_indent("$ ".magenta().into()) - .subsequent_indent(" ".into()), - ); - lines.extend(cmd_display); - - if let Some(output) = call.output.as_ref() { - if !call.is_unified_exec_interaction() { - let wrap_width = width.max(1) as usize; - let wrap_opts = RtOptions::new(wrap_width); - for unwrapped in output.formatted_output.lines().map(ansi_escape_line) { - let wrapped = word_wrap_line(&unwrapped, wrap_opts.clone()); - push_owned_lines(&wrapped, &mut lines); - } - } - let duration = call - .duration - .map(format_duration) - .unwrap_or_else(|| "unknown".to_string()); - let mut result: Line = if output.exit_code == 0 { - Line::from("✓".green().bold()) - } else { - Line::from(vec![ - "✗".red().bold(), - format!(" ({})", output.exit_code).into(), - ]) - }; - result.push_span(format!(" • {duration}").dim()); - lines.push(result); - } - } - lines - } -} - -impl ExecCell { - fn exploring_display_lines(&self, width: u16) -> Vec> { - let mut out: Vec> = Vec::new(); - out.push(Line::from(vec![ - if self.is_active() { - spinner(self.active_start_time(), self.animations_enabled()) - } else { - "•".dim() - }, - " ".into(), - if self.is_active() { - "Exploring".bold() - } else { - "Explored".bold() - }, - ])); - - let mut calls = self.calls.clone(); - let mut out_indented = Vec::new(); - while !calls.is_empty() { - let mut call = calls.remove(0); - if call - .parsed - .iter() - .all(|parsed| matches!(parsed, ParsedCommand::Read { .. })) - { - while let Some(next) = calls.first() { - if next - .parsed - .iter() - .all(|parsed| matches!(parsed, ParsedCommand::Read { .. })) - { - call.parsed.extend(next.parsed.clone()); - calls.remove(0); - } else { - break; - } - } - } - - let reads_only = call - .parsed - .iter() - .all(|parsed| matches!(parsed, ParsedCommand::Read { .. })); - - let call_lines: Vec<(&str, Vec>)> = if reads_only { - let names = call - .parsed - .iter() - .map(|parsed| match parsed { - ParsedCommand::Read { name, .. } => name.clone(), - _ => unreachable!(), - }) - .unique(); - vec![( - "Read", - Itertools::intersperse(names.into_iter().map(Into::into), ", ".dim()).collect(), - )] - } else { - let mut lines = Vec::new(); - for parsed in &call.parsed { - match parsed { - ParsedCommand::Read { name, .. } => { - lines.push(("Read", vec![name.clone().into()])); - } - ParsedCommand::ListFiles { cmd, path } => { - lines.push(("List", vec![path.clone().unwrap_or(cmd.clone()).into()])); - } - ParsedCommand::Search { cmd, query, path } => { - let spans = match (query, path) { - (Some(q), Some(p)) => { - vec![q.clone().into(), " in ".dim(), p.clone().into()] - } - (Some(q), None) => vec![q.clone().into()], - _ => vec![cmd.clone().into()], - }; - lines.push(("Search", spans)); - } - ParsedCommand::Unknown { cmd } => { - lines.push(("Run", vec![cmd.clone().into()])); - } - } - } - lines - }; - - for (title, line) in call_lines { - let line = Line::from(line); - let initial_indent = Line::from(vec![title.cyan(), " ".into()]); - let subsequent_indent = " ".repeat(initial_indent.width()).into(); - let wrapped = word_wrap_line( - &line, - RtOptions::new(width as usize) - .initial_indent(initial_indent) - .subsequent_indent(subsequent_indent), - ); - push_owned_lines(&wrapped, &mut out_indented); - } - } - - out.extend(prefix_lines(out_indented, " └ ".dim(), " ".into())); - out - } - - fn command_display_lines(&self, width: u16) -> Vec> { - let [call] = &self.calls.as_slice() else { - panic!("Expected exactly one call in a command display cell"); - }; - let layout = EXEC_DISPLAY_LAYOUT; - let success = call.output.as_ref().map(|o| o.exit_code == 0); - let bullet = match success { - Some(true) => "•".green().bold(), - Some(false) => "•".red().bold(), - None => spinner(call.start_time, self.animations_enabled()), - }; - let is_interaction = call.is_unified_exec_interaction(); - let title = if is_interaction { - "" - } else if self.is_active() { - "Running" - } else if call.is_user_shell_command() { - "You ran" - } else { - "Ran" - }; - - let mut header_line = if is_interaction { - Line::from(vec![bullet.clone(), " ".into()]) - } else { - Line::from(vec![bullet.clone(), " ".into(), title.bold(), " ".into()]) - }; - let header_prefix_width = header_line.width(); - - let cmd_display = if call.is_unified_exec_interaction() { - format_unified_exec_interaction(&call.command, call.interaction_input.as_deref()) - } else { - strip_bash_lc_and_escape(&call.command) - }; - let highlighted_lines = highlight_bash_to_lines(&cmd_display); - - let continuation_wrap_width = layout.command_continuation.wrap_width(width); - let continuation_opts = - RtOptions::new(continuation_wrap_width).word_splitter(WordSplitter::NoHyphenation); - - let mut continuation_lines: Vec> = Vec::new(); - - if let Some((first, rest)) = highlighted_lines.split_first() { - let available_first_width = (width as usize).saturating_sub(header_prefix_width).max(1); - let first_opts = - RtOptions::new(available_first_width).word_splitter(WordSplitter::NoHyphenation); - let mut first_wrapped: Vec> = Vec::new(); - push_owned_lines(&word_wrap_line(first, first_opts), &mut first_wrapped); - let mut first_wrapped_iter = first_wrapped.into_iter(); - if let Some(first_segment) = first_wrapped_iter.next() { - header_line.extend(first_segment); - } - continuation_lines.extend(first_wrapped_iter); - - for line in rest { - push_owned_lines( - &word_wrap_line(line, continuation_opts.clone()), - &mut continuation_lines, - ); - } - } - - let mut lines: Vec> = vec![header_line]; - - let continuation_lines = Self::limit_lines_from_start( - &continuation_lines, - layout.command_continuation_max_lines, - ); - if !continuation_lines.is_empty() { - lines.extend(prefix_lines( - continuation_lines, - Span::from(layout.command_continuation.initial_prefix).dim(), - Span::from(layout.command_continuation.subsequent_prefix).dim(), - )); - } - - if let Some(output) = call.output.as_ref() { - let line_limit = if call.is_user_shell_command() { - USER_SHELL_TOOL_CALL_MAX_LINES - } else { - TOOL_CALL_MAX_LINES - }; - let raw_output = output_lines( - Some(output), - OutputLinesParams { - line_limit, - only_err: false, - include_angle_pipe: false, - include_prefix: false, - }, - ); - let display_limit = if call.is_user_shell_command() { - USER_SHELL_TOOL_CALL_MAX_LINES - } else { - layout.output_max_lines - }; - - if raw_output.lines.is_empty() { - if !call.is_unified_exec_interaction() { - lines.extend(prefix_lines( - vec![Line::from("(no output)".dim())], - Span::from(layout.output_block.initial_prefix).dim(), - Span::from(layout.output_block.subsequent_prefix), - )); - } - } else { - // Wrap first so that truncation is applied to on-screen lines - // rather than logical lines. This ensures that a small number - // of very long lines cannot flood the viewport. - let mut wrapped_output: Vec> = Vec::new(); - let output_wrap_width = layout.output_block.wrap_width(width); - let output_opts = - RtOptions::new(output_wrap_width).word_splitter(WordSplitter::NoHyphenation); - for line in &raw_output.lines { - push_owned_lines( - &word_wrap_line(line, output_opts.clone()), - &mut wrapped_output, - ); - } - - let trimmed_output = - Self::truncate_lines_middle(&wrapped_output, display_limit, raw_output.omitted); - - if !trimmed_output.is_empty() { - lines.extend(prefix_lines( - trimmed_output, - Span::from(layout.output_block.initial_prefix).dim(), - Span::from(layout.output_block.subsequent_prefix), - )); - } - } - } - - lines - } - - fn limit_lines_from_start(lines: &[Line<'static>], keep: usize) -> Vec> { - if lines.len() <= keep { - return lines.to_vec(); - } - if keep == 0 { - return vec![Self::ellipsis_line(lines.len())]; - } - - let mut out: Vec> = lines[..keep].to_vec(); - out.push(Self::ellipsis_line(lines.len() - keep)); - out - } - - fn truncate_lines_middle( - lines: &[Line<'static>], - max: usize, - omitted_hint: Option, - ) -> Vec> { - if max == 0 { - return Vec::new(); - } - if lines.len() <= max { - return lines.to_vec(); - } - if max == 1 { - // Carry forward any previously omitted count and add any - // additionally hidden content lines from this truncation. - let base = omitted_hint.unwrap_or(0); - // When an existing ellipsis is present, `lines` already includes - // that single representation line; exclude it from the count of - // additionally omitted content lines. - let extra = lines - .len() - .saturating_sub(usize::from(omitted_hint.is_some())); - let omitted = base + extra; - return vec![Self::ellipsis_line(omitted)]; - } - - let head = (max - 1) / 2; - let tail = max - head - 1; - let mut out: Vec> = Vec::new(); - - if head > 0 { - out.extend(lines[..head].iter().cloned()); - } - - let base = omitted_hint.unwrap_or(0); - let additional = lines - .len() - .saturating_sub(head + tail) - .saturating_sub(usize::from(omitted_hint.is_some())); - out.push(Self::ellipsis_line(base + additional)); - - if tail > 0 { - out.extend(lines[lines.len() - tail..].iter().cloned()); - } - - out - } - - fn ellipsis_line(omitted: usize) -> Line<'static> { - Line::from(vec![format!("… +{omitted} lines").dim()]) - } -} - -#[derive(Clone, Copy)] -struct PrefixedBlock { - initial_prefix: &'static str, - subsequent_prefix: &'static str, -} - -impl PrefixedBlock { - const fn new(initial_prefix: &'static str, subsequent_prefix: &'static str) -> Self { - Self { - initial_prefix, - subsequent_prefix, - } - } - - fn wrap_width(self, total_width: u16) -> usize { - let prefix_width = UnicodeWidthStr::width(self.initial_prefix) - .max(UnicodeWidthStr::width(self.subsequent_prefix)); - usize::from(total_width).saturating_sub(prefix_width).max(1) - } -} - -#[derive(Clone, Copy)] -struct ExecDisplayLayout { - command_continuation: PrefixedBlock, - command_continuation_max_lines: usize, - output_block: PrefixedBlock, - output_max_lines: usize, -} - -impl ExecDisplayLayout { - const fn new( - command_continuation: PrefixedBlock, - command_continuation_max_lines: usize, - output_block: PrefixedBlock, - output_max_lines: usize, - ) -> Self { - Self { - command_continuation, - command_continuation_max_lines, - output_block, - output_max_lines, - } - } -} - -const EXEC_DISPLAY_LAYOUT: ExecDisplayLayout = ExecDisplayLayout::new( - PrefixedBlock::new(" │ ", " │ "), - 2, - PrefixedBlock::new(" └ ", " "), - 5, -); - -#[cfg(test)] -mod tests { - use super::*; - use codex_core::protocol::ExecCommandSource; - - #[test] - fn user_shell_output_is_limited_by_screen_lines() { - // Construct a user shell exec cell whose aggregated output consists of a - // small number of very long logical lines. These will wrap into many - // on-screen lines at narrow widths. - // - // Use a short marker so it survives wrapping intact inside each - // rendered screen line; the previous test used a marker longer than - // the wrap width, so it was split across lines and the assertion - // never actually saw it. - let marker = "Z"; - let long_chunk = marker.repeat(800); - let aggregated_output = format!("{long_chunk}\n{long_chunk}\n"); - - // Baseline: how many screen lines would we get if we simply wrapped - // all logical lines without any truncation? - let output = CommandOutput { - exit_code: 0, - aggregated_output, - formatted_output: String::new(), - }; - let width = 20; - let layout = EXEC_DISPLAY_LAYOUT; - let raw_output = output_lines( - Some(&output), - OutputLinesParams { - // Large enough to include all logical lines without - // triggering the ellipsis in `output_lines`. - line_limit: 100, - only_err: false, - include_angle_pipe: false, - include_prefix: false, - }, - ); - let output_wrap_width = layout.output_block.wrap_width(width); - let output_opts = - RtOptions::new(output_wrap_width).word_splitter(WordSplitter::NoHyphenation); - let mut full_wrapped_output: Vec> = Vec::new(); - for line in &raw_output.lines { - push_owned_lines( - &word_wrap_line(line, output_opts.clone()), - &mut full_wrapped_output, - ); - } - let full_screen_lines = full_wrapped_output - .iter() - .filter(|line| line.spans.iter().any(|span| span.content.contains(marker))) - .count(); - - // Sanity check: this scenario should produce more screen lines than - // the user shell per-call limit when no truncation is applied. If - // this ever fails, the test no longer exercises the regression. - assert!( - full_screen_lines > USER_SHELL_TOOL_CALL_MAX_LINES, - "expected unbounded wrapping to produce more than {USER_SHELL_TOOL_CALL_MAX_LINES} screen lines, got {full_screen_lines}", - ); - - let call = ExecCall { - call_id: "call-id".to_string(), - command: vec!["bash".into(), "-lc".into(), "echo long".into()], - parsed: Vec::new(), - output: Some(output), - source: ExecCommandSource::UserShell, - start_time: None, - duration: None, - interaction_input: None, - }; - - let cell = ExecCell::new(call, false); - - // Use a narrow width so each logical line wraps into many on-screen lines. - let lines = cell.command_display_lines(width); - - // Count how many rendered lines contain our marker text. This approximates - // the number of visible output "screen lines" for this command. - let output_screen_lines = lines - .iter() - .filter(|line| line.spans.iter().any(|span| span.content.contains(marker))) - .count(); - - // Regression guard: previously this scenario could render hundreds of - // wrapped lines because truncation happened before wrapping. Now the - // truncation is applied after wrapping, so the number of visible - // screen lines is bounded by USER_SHELL_TOOL_CALL_MAX_LINES. - assert!( - output_screen_lines <= USER_SHELL_TOOL_CALL_MAX_LINES, - "expected at most {USER_SHELL_TOOL_CALL_MAX_LINES} screen lines of user shell output, got {output_screen_lines}", - ); - } -} diff --git a/codex-rs/tui2/src/exec_command.rs b/codex-rs/tui2/src/exec_command.rs deleted file mode 100644 index 8ce6c2632e4..00000000000 --- a/codex-rs/tui2/src/exec_command.rs +++ /dev/null @@ -1,70 +0,0 @@ -use std::path::Path; -use std::path::PathBuf; - -use codex_core::parse_command::extract_shell_command; -use dirs::home_dir; -use shlex::try_join; - -pub(crate) fn escape_command(command: &[String]) -> String { - try_join(command.iter().map(String::as_str)).unwrap_or_else(|_| command.join(" ")) -} - -pub(crate) fn strip_bash_lc_and_escape(command: &[String]) -> String { - if let Some((_, script)) = extract_shell_command(command) { - return script.to_string(); - } - escape_command(command) -} - -/// If `path` is absolute and inside $HOME, return the part *after* the home -/// directory; otherwise, return the path as-is. Note if `path` is the homedir, -/// this will return and empty path. -pub(crate) fn relativize_to_home

(path: P) -> Option -where - P: AsRef, -{ - let path = path.as_ref(); - if !path.is_absolute() { - // If the path is not absolute, we can’t do anything with it. - return None; - } - - let home_dir = home_dir()?; - let rel = path.strip_prefix(&home_dir).ok()?; - Some(rel.to_path_buf()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_escape_command() { - let args = vec!["foo".into(), "bar baz".into(), "weird&stuff".into()]; - let cmdline = escape_command(&args); - assert_eq!(cmdline, "foo 'bar baz' 'weird&stuff'"); - } - - #[test] - fn test_strip_bash_lc_and_escape() { - // Test bash - let args = vec!["bash".into(), "-lc".into(), "echo hello".into()]; - let cmdline = strip_bash_lc_and_escape(&args); - assert_eq!(cmdline, "echo hello"); - - // Test zsh - let args = vec!["zsh".into(), "-lc".into(), "echo hello".into()]; - let cmdline = strip_bash_lc_and_escape(&args); - assert_eq!(cmdline, "echo hello"); - - // Test absolute path to zsh - let args = vec!["/usr/bin/zsh".into(), "-lc".into(), "echo hello".into()]; - let cmdline = strip_bash_lc_and_escape(&args); - assert_eq!(cmdline, "echo hello"); - - // Test absolute path to bash - let args = vec!["/bin/bash".into(), "-lc".into(), "echo hello".into()]; - let cmdline = strip_bash_lc_and_escape(&args); - assert_eq!(cmdline, "echo hello"); - } -} diff --git a/codex-rs/tui2/src/file_search.rs b/codex-rs/tui2/src/file_search.rs deleted file mode 100644 index af465126400..00000000000 --- a/codex-rs/tui2/src/file_search.rs +++ /dev/null @@ -1,199 +0,0 @@ -//! Helper that owns the debounce/cancellation logic for `@` file searches. -//! -//! `ChatComposer` publishes *every* change of the `@token` as -//! `AppEvent::StartFileSearch(query)`. -//! This struct receives those events and decides when to actually spawn the -//! expensive search (handled in the main `App` thread). It tries to ensure: -//! -//! - Even when the user types long text quickly, they will start seeing results -//! after a short delay using an early version of what they typed. -//! - At most one search is in-flight at any time. -//! -//! It works as follows: -//! -//! 1. First query starts a debounce timer. -//! 2. While the timer is pending, the latest query from the user is stored. -//! 3. When the timer fires, it is cleared, and a search is done for the most -//! recent query. -//! 4. If there is a in-flight search that is not a prefix of the latest thing -//! the user typed, it is cancelled. - -use codex_file_search as file_search; -use std::num::NonZeroUsize; -use std::path::PathBuf; -use std::sync::Arc; -use std::sync::Mutex; -use std::sync::atomic::AtomicBool; -use std::sync::atomic::Ordering; -use std::thread; -use std::time::Duration; - -use crate::app_event::AppEvent; -use crate::app_event_sender::AppEventSender; - -const MAX_FILE_SEARCH_RESULTS: NonZeroUsize = NonZeroUsize::new(20).unwrap(); -const NUM_FILE_SEARCH_THREADS: NonZeroUsize = NonZeroUsize::new(2).unwrap(); - -/// How long to wait after a keystroke before firing the first search when none -/// is currently running. Keeps early queries more meaningful. -const FILE_SEARCH_DEBOUNCE: Duration = Duration::from_millis(100); - -const ACTIVE_SEARCH_COMPLETE_POLL_INTERVAL: Duration = Duration::from_millis(20); - -/// State machine for file-search orchestration. -pub(crate) struct FileSearchManager { - /// Unified state guarded by one mutex. - state: Arc>, - - search_dir: PathBuf, - app_tx: AppEventSender, -} - -struct SearchState { - /// Latest query typed by user (updated every keystroke). - latest_query: String, - - /// true if a search is currently scheduled. - is_search_scheduled: bool, - - /// If there is an active search, this will be the query being searched. - active_search: Option, -} - -struct ActiveSearch { - query: String, - cancellation_token: Arc, -} - -impl FileSearchManager { - pub fn new(search_dir: PathBuf, tx: AppEventSender) -> Self { - Self { - state: Arc::new(Mutex::new(SearchState { - latest_query: String::new(), - is_search_scheduled: false, - active_search: None, - })), - search_dir, - app_tx: tx, - } - } - - /// Call whenever the user edits the `@` token. - pub fn on_user_query(&self, query: String) { - { - #[expect(clippy::unwrap_used)] - let mut st = self.state.lock().unwrap(); - if query == st.latest_query { - // No change, nothing to do. - return; - } - - // Update latest query. - st.latest_query.clear(); - st.latest_query.push_str(&query); - - // If there is an in-flight search that is definitely obsolete, - // cancel it now. - if let Some(active_search) = &st.active_search - && !query.starts_with(&active_search.query) - { - active_search - .cancellation_token - .store(true, Ordering::Relaxed); - st.active_search = None; - } - - // Schedule a search to run after debounce. - if !st.is_search_scheduled { - st.is_search_scheduled = true; - } else { - return; - } - } - - // If we are here, we set `st.is_search_scheduled = true` before - // dropping the lock. This means we are the only thread that can spawn a - // debounce timer. - let state = self.state.clone(); - let search_dir = self.search_dir.clone(); - let tx_clone = self.app_tx.clone(); - thread::spawn(move || { - // Always do a minimum debounce, but then poll until the - // `active_search` is cleared. - thread::sleep(FILE_SEARCH_DEBOUNCE); - loop { - #[expect(clippy::unwrap_used)] - if state.lock().unwrap().active_search.is_none() { - break; - } - thread::sleep(ACTIVE_SEARCH_COMPLETE_POLL_INTERVAL); - } - - // The debounce timer has expired, so start a search using the - // latest query. - let cancellation_token = Arc::new(AtomicBool::new(false)); - let token = cancellation_token.clone(); - let query = { - #[expect(clippy::unwrap_used)] - let mut st = state.lock().unwrap(); - let query = st.latest_query.clone(); - st.is_search_scheduled = false; - st.active_search = Some(ActiveSearch { - query: query.clone(), - cancellation_token: token, - }); - query - }; - - FileSearchManager::spawn_file_search( - query, - search_dir, - tx_clone, - cancellation_token, - state, - ); - }); - } - - fn spawn_file_search( - query: String, - search_dir: PathBuf, - tx: AppEventSender, - cancellation_token: Arc, - search_state: Arc>, - ) { - let compute_indices = true; - std::thread::spawn(move || { - let matches = file_search::run( - &query, - MAX_FILE_SEARCH_RESULTS, - &search_dir, - Vec::new(), - NUM_FILE_SEARCH_THREADS, - cancellation_token.clone(), - compute_indices, - true, - ) - .map(|res| res.matches) - .unwrap_or_default(); - - let is_cancelled = cancellation_token.load(Ordering::Relaxed); - if !is_cancelled { - tx.send(AppEvent::FileSearchResult { query, matches }); - } - - // Reset the active search state. Do a pointer comparison to verify - // that we are clearing the ActiveSearch that corresponds to the - // cancellation token we were given. - { - #[expect(clippy::unwrap_used)] - let mut st = search_state.lock().unwrap(); - if let Some(active_search) = &st.active_search - && Arc::ptr_eq(&active_search.cancellation_token, &cancellation_token) - { - st.active_search = None; - } - } - }); - } -} diff --git a/codex-rs/tui2/src/frames.rs b/codex-rs/tui2/src/frames.rs deleted file mode 100644 index 19a70578d48..00000000000 --- a/codex-rs/tui2/src/frames.rs +++ /dev/null @@ -1,71 +0,0 @@ -use std::time::Duration; - -// Embed animation frames for each variant at compile time. -macro_rules! frames_for { - ($dir:literal) => { - [ - include_str!(concat!("../frames/", $dir, "/frame_1.txt")), - include_str!(concat!("../frames/", $dir, "/frame_2.txt")), - include_str!(concat!("../frames/", $dir, "/frame_3.txt")), - include_str!(concat!("../frames/", $dir, "/frame_4.txt")), - include_str!(concat!("../frames/", $dir, "/frame_5.txt")), - include_str!(concat!("../frames/", $dir, "/frame_6.txt")), - include_str!(concat!("../frames/", $dir, "/frame_7.txt")), - include_str!(concat!("../frames/", $dir, "/frame_8.txt")), - include_str!(concat!("../frames/", $dir, "/frame_9.txt")), - include_str!(concat!("../frames/", $dir, "/frame_10.txt")), - include_str!(concat!("../frames/", $dir, "/frame_11.txt")), - include_str!(concat!("../frames/", $dir, "/frame_12.txt")), - include_str!(concat!("../frames/", $dir, "/frame_13.txt")), - include_str!(concat!("../frames/", $dir, "/frame_14.txt")), - include_str!(concat!("../frames/", $dir, "/frame_15.txt")), - include_str!(concat!("../frames/", $dir, "/frame_16.txt")), - include_str!(concat!("../frames/", $dir, "/frame_17.txt")), - include_str!(concat!("../frames/", $dir, "/frame_18.txt")), - include_str!(concat!("../frames/", $dir, "/frame_19.txt")), - include_str!(concat!("../frames/", $dir, "/frame_20.txt")), - include_str!(concat!("../frames/", $dir, "/frame_21.txt")), - include_str!(concat!("../frames/", $dir, "/frame_22.txt")), - include_str!(concat!("../frames/", $dir, "/frame_23.txt")), - include_str!(concat!("../frames/", $dir, "/frame_24.txt")), - include_str!(concat!("../frames/", $dir, "/frame_25.txt")), - include_str!(concat!("../frames/", $dir, "/frame_26.txt")), - include_str!(concat!("../frames/", $dir, "/frame_27.txt")), - include_str!(concat!("../frames/", $dir, "/frame_28.txt")), - include_str!(concat!("../frames/", $dir, "/frame_29.txt")), - include_str!(concat!("../frames/", $dir, "/frame_30.txt")), - include_str!(concat!("../frames/", $dir, "/frame_31.txt")), - include_str!(concat!("../frames/", $dir, "/frame_32.txt")), - include_str!(concat!("../frames/", $dir, "/frame_33.txt")), - include_str!(concat!("../frames/", $dir, "/frame_34.txt")), - include_str!(concat!("../frames/", $dir, "/frame_35.txt")), - include_str!(concat!("../frames/", $dir, "/frame_36.txt")), - ] - }; -} - -pub(crate) const FRAMES_DEFAULT: [&str; 36] = frames_for!("default"); -pub(crate) const FRAMES_CODEX: [&str; 36] = frames_for!("codex"); -pub(crate) const FRAMES_OPENAI: [&str; 36] = frames_for!("openai"); -pub(crate) const FRAMES_BLOCKS: [&str; 36] = frames_for!("blocks"); -pub(crate) const FRAMES_DOTS: [&str; 36] = frames_for!("dots"); -pub(crate) const FRAMES_HASH: [&str; 36] = frames_for!("hash"); -pub(crate) const FRAMES_HBARS: [&str; 36] = frames_for!("hbars"); -pub(crate) const FRAMES_VBARS: [&str; 36] = frames_for!("vbars"); -pub(crate) const FRAMES_SHAPES: [&str; 36] = frames_for!("shapes"); -pub(crate) const FRAMES_SLUG: [&str; 36] = frames_for!("slug"); - -pub(crate) const ALL_VARIANTS: &[&[&str]] = &[ - &FRAMES_DEFAULT, - &FRAMES_CODEX, - &FRAMES_OPENAI, - &FRAMES_BLOCKS, - &FRAMES_DOTS, - &FRAMES_HASH, - &FRAMES_HBARS, - &FRAMES_VBARS, - &FRAMES_SHAPES, - &FRAMES_SLUG, -]; - -pub(crate) const FRAME_TICK_DEFAULT: Duration = Duration::from_millis(80); diff --git a/codex-rs/tui2/src/get_git_diff.rs b/codex-rs/tui2/src/get_git_diff.rs deleted file mode 100644 index 78ab53d92f6..00000000000 --- a/codex-rs/tui2/src/get_git_diff.rs +++ /dev/null @@ -1,119 +0,0 @@ -//! Utility to compute the current Git diff for the working directory. -//! -//! The implementation mirrors the behaviour of the TypeScript version in -//! `codex-cli`: it returns the diff for tracked changes as well as any -//! untracked files. When the current directory is not inside a Git -//! repository, the function returns `Ok((false, String::new()))`. - -use std::io; -use std::path::Path; -use std::process::Stdio; -use tokio::process::Command; - -/// Return value of [`get_git_diff`]. -/// -/// * `bool` – Whether the current working directory is inside a Git repo. -/// * `String` – The concatenated diff (may be empty). -pub(crate) async fn get_git_diff() -> io::Result<(bool, String)> { - // First check if we are inside a Git repository. - if !inside_git_repo().await? { - return Ok((false, String::new())); - } - - // Run tracked diff and untracked file listing in parallel. - let (tracked_diff_res, untracked_output_res) = tokio::join!( - run_git_capture_diff(&["diff", "--color"]), - run_git_capture_stdout(&["ls-files", "--others", "--exclude-standard"]), - ); - let tracked_diff = tracked_diff_res?; - let untracked_output = untracked_output_res?; - - let mut untracked_diff = String::new(); - let null_device: &Path = if cfg!(windows) { - Path::new("NUL") - } else { - Path::new("/dev/null") - }; - - let null_path = null_device.to_str().unwrap_or("/dev/null").to_string(); - let mut join_set: tokio::task::JoinSet> = tokio::task::JoinSet::new(); - for file in untracked_output - .split('\n') - .map(str::trim) - .filter(|s| !s.is_empty()) - { - let null_path = null_path.clone(); - let file = file.to_string(); - join_set.spawn(async move { - let args = ["diff", "--color", "--no-index", "--", &null_path, &file]; - run_git_capture_diff(&args).await - }); - } - while let Some(res) = join_set.join_next().await { - match res { - Ok(Ok(diff)) => untracked_diff.push_str(&diff), - Ok(Err(err)) if err.kind() == io::ErrorKind::NotFound => {} - Ok(Err(err)) => return Err(err), - Err(_) => {} - } - } - - Ok((true, format!("{tracked_diff}{untracked_diff}"))) -} - -/// Helper that executes `git` with the given `args` and returns `stdout` as a -/// UTF-8 string. Any non-zero exit status is considered an *error*. -async fn run_git_capture_stdout(args: &[&str]) -> io::Result { - let output = Command::new("git") - .args(args) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .output() - .await?; - - if output.status.success() { - Ok(String::from_utf8_lossy(&output.stdout).into_owned()) - } else { - Err(io::Error::other(format!( - "git {:?} failed with status {}", - args, output.status - ))) - } -} - -/// Like [`run_git_capture_stdout`] but treats exit status 1 as success and -/// returns stdout. Git returns 1 for diffs when differences are present. -async fn run_git_capture_diff(args: &[&str]) -> io::Result { - let output = Command::new("git") - .args(args) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .output() - .await?; - - if output.status.success() || output.status.code() == Some(1) { - Ok(String::from_utf8_lossy(&output.stdout).into_owned()) - } else { - Err(io::Error::other(format!( - "git {:?} failed with status {}", - args, output.status - ))) - } -} - -/// Determine if the current directory is inside a Git repository. -async fn inside_git_repo() -> io::Result { - let status = Command::new("git") - .args(["rev-parse", "--is-inside-work-tree"]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .await; - - match status { - Ok(s) if s.success() => Ok(true), - Ok(_) => Ok(false), - Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false), // git not installed - Err(e) => Err(e), - } -} diff --git a/codex-rs/tui2/src/history_cell.rs b/codex-rs/tui2/src/history_cell.rs deleted file mode 100644 index a4dc77a2576..00000000000 --- a/codex-rs/tui2/src/history_cell.rs +++ /dev/null @@ -1,3093 +0,0 @@ -//! Transcript/history cells for the Codex TUI. -//! -//! A `HistoryCell` is the unit of display in the conversation UI, representing both committed -//! transcript entries and, transiently, an in-flight active cell that can mutate in place while -//! streaming. -//! -//! The transcript overlay (`Ctrl+T`) appends a cached live tail derived from the active cell, and -//! that cached tail is refreshed based on an active-cell cache key. Cells that change based on -//! elapsed time expose `transcript_animation_tick()`, and code that mutates the active cell in place -//! bumps the active-cell revision tracked by `ChatWidget`, so the cache key changes whenever the -//! rendered transcript output can change. - -use crate::diff_render::create_diff_summary; -use crate::diff_render::display_path_for; -use crate::exec_cell::CommandOutput; -use crate::exec_cell::OutputLinesParams; -use crate::exec_cell::TOOL_CALL_MAX_LINES; -use crate::exec_cell::output_lines; -use crate::exec_cell::spinner; -use crate::exec_command::relativize_to_home; -use crate::exec_command::strip_bash_lc_and_escape; -use crate::key_hint; -use crate::markdown::append_markdown; -use crate::render::line_utils::line_to_static; -use crate::render::line_utils::prefix_lines; -use crate::render::renderable::Renderable; -use crate::style::user_message_style; -use crate::text_formatting::format_and_truncate_tool_result; -use crate::text_formatting::truncate_text; -use crate::tooltips; -use crate::ui_consts::DEFAULT_MODEL_DISPLAY_NAME; -use crate::ui_consts::LIVE_PREFIX_COLS; -use crate::update_action::UpdateAction; -use crate::version::CODEX_CLI_VERSION; -use crate::wrapping::RtOptions; -use crate::wrapping::word_wrap_line; -use base64::Engine; -use codex_common::format_env_display::format_env_display; -use codex_core::config::Config; -use codex_core::config::types::McpServerTransportConfig; -use codex_core::protocol::FileChange; -use codex_core::protocol::McpAuthStatus; -use codex_core::protocol::McpInvocation; -use codex_core::protocol::SessionConfiguredEvent; -use codex_protocol::config_types::CollaborationMode; -use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; -use codex_protocol::plan_tool::PlanItemArg; -use codex_protocol::plan_tool::StepStatus; -use codex_protocol::plan_tool::UpdatePlanArgs; -use codex_protocol::user_input::TextElement; -use crossterm::event::KeyCode; -use image::DynamicImage; -use image::ImageReader; -use mcp_types::EmbeddedResourceResource; -use mcp_types::Resource; -use mcp_types::ResourceLink; -use mcp_types::ResourceTemplate; -use ratatui::prelude::*; -use ratatui::style::Color; -use ratatui::style::Modifier; -use ratatui::style::Style; -use ratatui::style::Styled; -use ratatui::style::Stylize; -use ratatui::widgets::Paragraph; -use ratatui::widgets::Wrap; -use std::any::Any; -use std::collections::HashMap; -use std::io::Cursor; -use std::path::Path; -use std::path::PathBuf; -use std::time::Duration; -use std::time::Instant; -use tracing::error; -use unicode_width::UnicodeWidthStr; - -/// Visual transcript lines plus soft-wrap joiners. -/// -/// A history cell can produce multiple "visual lines" once prefixes/indents and wrapping are -/// applied. Clipboard reconstruction needs more information than just those lines because users -/// expect soft-wrapped prose to copy as a single logical line, while explicit newlines and spacer -/// rows should remain hard breaks. -/// -/// `joiner_before` records, for each output line, whether it is a continuation created by the -/// wrapping algorithm and what string should be inserted at the wrap boundary when joining lines. -/// This avoids heuristics like always inserting a space, and instead preserves the exact whitespace -/// that was skipped at the boundary. -/// -/// In `codex-tui`, `HistoryCell` only exposes `transcript_lines(...)` and the UI generally does not -/// need to reconstruct clipboard text across off-screen history or soft-wrap boundaries. In -/// `codex-tui2`, transcript selection and copy are app-driven (not terminal-driven) and may span -/// content that is not currently visible, so we need extra metadata to distinguish hard breaks from -/// soft wraps and to preserve the exact whitespace at wrap boundaries. -/// -/// The invariant is that `joiner_before.len() == lines.len()` and `joiner_before[0]` is always -/// `None`. A `None` entry represents a hard break (copy inserts a newline), while `Some(joiner)` -/// represents a soft wrap continuation (copy inserts `joiner` and continues on the same logical -/// line). This data is produced by transcript rendering and consumed by transcript copy to keep -/// clipboard output faithful to what the user saw. -#[derive(Debug, Clone)] -pub(crate) struct TranscriptLinesWithJoiners { - /// Visual transcript lines for a history cell, including any indent/prefix spans. - /// - /// This is the same shape used for on-screen transcript rendering: a single cell may expand - /// to multiple `Line`s after wrapping and prefixing. - pub(crate) lines: Vec>, - /// For each output line, whether and how to join it to the previous line when copying. - pub(crate) joiner_before: Vec>, -} - -/// Represents an event to display in the conversation history. Returns its -/// `Vec>` representation to make it easier to display in a -/// scrollable list. -pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any { - fn display_lines(&self, width: u16) -> Vec>; - - fn desired_height(&self, width: u16) -> u16 { - Paragraph::new(Text::from(self.display_lines(width))) - .wrap(Wrap { trim: false }) - .line_count(width) - .try_into() - .unwrap_or(0) - } - - fn transcript_lines(&self, width: u16) -> Vec> { - self.display_lines(width) - } - - /// Transcript lines plus soft-wrap joiners used for copy/paste fidelity. - /// - /// Most cells can use the default implementation (no joiners), but cells that apply wrapping - /// should override this and return joiners derived from the same wrapping operation so - /// clipboard reconstruction can distinguish hard breaks from soft wraps. - /// - /// `joiner_before[i]` describes the boundary *between* `lines[i - 1]` and `lines[i]`: - /// - /// - `None` means "hard break": copy inserts a newline between the two lines. - /// - `Some(joiner)` means "soft wrap continuation": copy inserts `joiner` and continues on the - /// same logical line. - /// - /// Example (one logical line wrapped across two visual lines): - /// - /// - `lines = ["• Hello", " world"]` - /// - `joiner_before = [None, Some(\" \")]` - /// - /// Copy should produce `"Hello world"` (no hard newline). - fn transcript_lines_with_joiners(&self, width: u16) -> TranscriptLinesWithJoiners { - let lines = self.transcript_lines(width); - TranscriptLinesWithJoiners { - joiner_before: vec![None; lines.len()], - lines, - } - } - - fn desired_transcript_height(&self, width: u16) -> u16 { - let lines = self.transcript_lines(width); - // Workaround for ratatui bug: if there's only one line and it's whitespace-only, ratatui gives 2 lines. - if let [line] = &lines[..] - && line - .spans - .iter() - .all(|s| s.content.chars().all(char::is_whitespace)) - { - return 1; - } - - Paragraph::new(Text::from(lines)) - .wrap(Wrap { trim: false }) - .line_count(width) - .try_into() - .unwrap_or(0) - } - - fn is_stream_continuation(&self) -> bool { - false - } - - /// Returns a coarse "animation tick" when transcript output is time-dependent. - /// - /// The transcript overlay caches the rendered output of the in-flight active cell, so cells - /// that include time-based UI (spinner, shimmer, etc.) should return a tick that changes over - /// time to signal that the cached tail should be recomputed. Returning `None` means the - /// transcript lines are stable, while returning `Some(tick)` during an in-flight animation - /// allows the overlay to keep up with the main viewport. - /// - /// If a cell uses time-based visuals but always returns `None`, `Ctrl+T` can appear "frozen" on - /// the first rendered frame even though the main viewport is animating. - fn transcript_animation_tick(&self) -> Option { - None - } -} - -impl Renderable for Box { - fn render(&self, area: Rect, buf: &mut Buffer) { - let lines = self.display_lines(area.width); - let y = if area.height == 0 { - 0 - } else { - let overflow = lines.len().saturating_sub(usize::from(area.height)); - u16::try_from(overflow).unwrap_or(u16::MAX) - }; - Paragraph::new(Text::from(lines)) - .scroll((y, 0)) - .render(area, buf); - } - fn desired_height(&self, width: u16) -> u16 { - HistoryCell::desired_height(self.as_ref(), width) - } -} - -impl dyn HistoryCell { - pub(crate) fn as_any(&self) -> &dyn Any { - self - } - - pub(crate) fn as_any_mut(&mut self) -> &mut dyn Any { - self - } -} - -#[derive(Debug)] -pub(crate) struct UserHistoryCell { - pub message: String, - pub text_elements: Vec, - #[allow(dead_code)] - pub local_image_paths: Vec, -} - -/// Build logical lines for a user message with styled text elements. -/// -/// This preserves explicit newlines while interleaving element spans and skips -/// malformed byte ranges instead of panicking during history rendering. -fn build_user_message_lines_with_elements( - message: &str, - elements: &[TextElement], - style: Style, - element_style: Style, -) -> Vec> { - let mut elements = elements.to_vec(); - elements.sort_by_key(|e| e.byte_range.start); - let mut offset = 0usize; - let mut raw_lines: Vec> = Vec::new(); - for line_text in message.split('\n') { - let line_start = offset; - let line_end = line_start + line_text.len(); - let mut spans: Vec> = Vec::new(); - // Track how much of the line we've emitted to interleave plain and styled spans. - let mut cursor = line_start; - for elem in &elements { - let start = elem.byte_range.start.max(line_start); - let end = elem.byte_range.end.min(line_end); - if start >= end { - continue; - } - let rel_start = start - line_start; - let rel_end = end - line_start; - // Guard against malformed UTF-8 byte ranges from upstream data; skip - // invalid elements rather than panicking while rendering history. - if !line_text.is_char_boundary(rel_start) || !line_text.is_char_boundary(rel_end) { - continue; - } - let rel_cursor = cursor - line_start; - if cursor < start - && line_text.is_char_boundary(rel_cursor) - && let Some(segment) = line_text.get(rel_cursor..rel_start) - { - spans.push(Span::from(segment.to_string())); - } - if let Some(segment) = line_text.get(rel_start..rel_end) { - spans.push(Span::styled(segment.to_string(), element_style)); - cursor = end; - } - } - let rel_cursor = cursor - line_start; - if cursor < line_end - && line_text.is_char_boundary(rel_cursor) - && let Some(segment) = line_text.get(rel_cursor..) - { - spans.push(Span::from(segment.to_string())); - } - let line = if spans.is_empty() { - Line::from(line_text.to_string()).style(style) - } else { - Line::from(spans).style(style) - }; - raw_lines.push(line); - // Split on '\n' so any '\r' stays in the line; advancing by 1 accounts - // for the separator byte. - offset = line_end + 1; - } - - raw_lines -} - -impl HistoryCell for UserHistoryCell { - fn display_lines(&self, width: u16) -> Vec> { - self.transcript_lines_with_joiners(width).lines - } - - fn transcript_lines_with_joiners(&self, width: u16) -> TranscriptLinesWithJoiners { - let wrap_width = width - .saturating_sub( - LIVE_PREFIX_COLS + 1, /* keep a one-column right margin for wrapping */ - ) - .max(1); - - let style = user_message_style(); - let element_style = style.fg(Color::Cyan); - - let (wrapped, joiner_before) = if self.text_elements.is_empty() { - crate::wrapping::word_wrap_lines_with_joiners( - self.message.split('\n').map(|l| Line::from(l).style(style)), - // Wrap algorithm matches textarea.rs. - RtOptions::new(usize::from(wrap_width)) - .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), - ) - } else { - let raw_lines = build_user_message_lines_with_elements( - &self.message, - &self.text_elements, - style, - element_style, - ); - crate::wrapping::word_wrap_lines_with_joiners( - raw_lines, - RtOptions::new(usize::from(wrap_width)) - .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), - ) - }; - - let mut lines: Vec> = Vec::new(); - let mut joins: Vec> = Vec::new(); - - lines.push(Line::from("").style(style)); - joins.push(None); - - let prefixed = prefix_lines(wrapped, "› ".bold().dim(), " ".into()); - for (line, joiner) in prefixed.into_iter().zip(joiner_before) { - lines.push(line); - joins.push(joiner); - } - - lines.push(Line::from("").style(style)); - joins.push(None); - - TranscriptLinesWithJoiners { - lines, - joiner_before: joins, - } - } -} - -#[derive(Debug)] -pub(crate) struct ReasoningSummaryCell { - _header: String, - content: String, - transcript_only: bool, -} - -impl ReasoningSummaryCell { - pub(crate) fn new(header: String, content: String, transcript_only: bool) -> Self { - Self { - _header: header, - content, - transcript_only, - } - } - - fn lines(&self, width: u16) -> Vec> { - self.lines_with_joiners(width).lines - } - - fn lines_with_joiners(&self, width: u16) -> TranscriptLinesWithJoiners { - let mut lines: Vec> = Vec::new(); - append_markdown( - &self.content, - Some((width as usize).saturating_sub(2)), - &mut lines, - ); - let summary_style = Style::default().dim().italic(); - let summary_lines = lines - .into_iter() - .map(|mut line| { - line.spans = line - .spans - .into_iter() - .map(|span| span.patch_style(summary_style)) - .collect(); - line - }) - .collect::>(); - - let (lines, joiner_before) = crate::wrapping::word_wrap_lines_with_joiners( - &summary_lines, - RtOptions::new(width as usize) - .initial_indent("• ".dim().into()) - .subsequent_indent(" ".into()), - ); - - TranscriptLinesWithJoiners { - lines, - joiner_before, - } - } -} - -impl HistoryCell for ReasoningSummaryCell { - fn display_lines(&self, width: u16) -> Vec> { - if self.transcript_only { - Vec::new() - } else { - self.lines(width) - } - } - - fn desired_height(&self, width: u16) -> u16 { - if self.transcript_only { - 0 - } else { - self.lines(width).len() as u16 - } - } - - fn transcript_lines(&self, width: u16) -> Vec> { - self.lines(width) - } - - fn transcript_lines_with_joiners(&self, width: u16) -> TranscriptLinesWithJoiners { - self.lines_with_joiners(width) - } - - fn desired_transcript_height(&self, width: u16) -> u16 { - self.lines(width).len() as u16 - } -} - -#[derive(Debug)] -pub(crate) struct AgentMessageCell { - /// Width-agnostic logical markdown lines for this chunk. - /// - /// These are produced either: - /// - by streaming (`markdown_stream` → `markdown_render::render_markdown_logical_lines`), or - /// - by legacy/non-streaming callers that pass pre-rendered `Vec` via [`Self::new`]. - /// - /// Importantly, this stores *logical* lines, not already-wrapped visual lines, so the transcript - /// can reflow on resize. - logical_lines: Vec, - /// Whether this cell should render the leading transcript bullet (`• `). - /// - /// Streaming emits multiple immutable `AgentMessageCell`s per assistant message; only the first - /// chunk shows the bullet. Continuations use a two-space gutter. - is_first_line: bool, -} - -impl AgentMessageCell { - /// Construct an agent message cell from already-rendered `Line`s. - /// - /// This is primarily used by non-streaming paths. The lines are treated as already "logical" - /// lines (no additional markdown indentation metadata is available), and wrapping is still - /// performed at render time so the transcript can reflow on resize. - pub(crate) fn new(lines: Vec>, is_first_line: bool) -> Self { - Self { - logical_lines: lines - .into_iter() - .map(|line| { - let is_preformatted = line.style.fg == Some(ratatui::style::Color::Cyan); - let line_style = line.style; - let content = Line { - style: Style::default(), - alignment: line.alignment, - spans: line.spans, - }; - crate::markdown_render::MarkdownLogicalLine { - content, - initial_indent: Line::default(), - subsequent_indent: Line::default(), - line_style, - is_preformatted, - } - }) - .collect(), - is_first_line, - } - } - - /// Construct an agent message cell from markdown logical lines. - /// - /// This is the preferred streaming constructor: it preserves markdown indentation rules (list - /// markers, nested list continuation indent, blockquote prefix, etc.) so wrapping can be - /// performed correctly at render time for the current viewport width. - pub(crate) fn new_logical( - logical_lines: Vec, - is_first_line: bool, - ) -> Self { - Self { - logical_lines, - is_first_line, - } - } -} - -impl HistoryCell for AgentMessageCell { - fn display_lines(&self, width: u16) -> Vec> { - self.transcript_lines_with_joiners(width).lines - } - - /// Render wrapped transcript lines plus soft-wrap joiners. - /// - /// This is where width-dependent wrapping happens for streaming agent output. The cell composes - /// indentation as: - /// - /// - transcript gutter (`• ` or ` `), plus - /// - markdown-provided indent/prefix spans (`initial_indent` / `subsequent_indent`) - /// - /// The wrapping algorithm returns a `joiner_before` vector so copy/paste can treat soft wraps - /// as joinable (no hard newline) while preserving exact whitespace at wrap boundaries. - fn transcript_lines_with_joiners(&self, width: u16) -> TranscriptLinesWithJoiners { - if width == 0 { - return TranscriptLinesWithJoiners { - lines: Vec::new(), - joiner_before: Vec::new(), - }; - } - - let mut out_lines: Vec> = Vec::new(); - let mut joiner_before: Vec> = Vec::new(); - - // `at_cell_start` tracks whether we're about to emit the first *visual* line of this cell. - // Only the first chunk of a streamed message gets the `• ` gutter; continuations use ` `. - let mut at_cell_start = true; - for logical in &self.logical_lines { - let gutter_first_visual_line: Line<'static> = if at_cell_start && self.is_first_line { - "• ".dim().into() - } else { - " ".into() - }; - let gutter_continuation: Line<'static> = " ".into(); - - // Compose the transcript gutter with markdown-provided indentation: - // - // - `gutter_*` is the transcript-level prefix (`• ` / ` `). - // - `initial_indent` / `subsequent_indent` come from markdown structure (blockquote - // prefix, list marker indentation, nested list continuation indentation, etc.). - // - // We apply these indents during wrapping so: - // - the UI renders with correct continuation indentation, and - // - soft-wrap joiners stay aligned with the exact whitespace the wrapper skipped. - let compose_indent = - |gutter: &Line<'static>, md_indent: &Line<'static>| -> Line<'static> { - let mut spans = gutter.spans.clone(); - spans.extend(md_indent.spans.iter().cloned()); - Line::from(spans) - }; - - // Preformatted lines are rendered as a single visual line (no wrapping). - // This preserves code-block whitespace and keeps code copy behavior stable. - if logical.is_preformatted { - let mut spans = gutter_first_visual_line.spans.clone(); - spans.extend(logical.initial_indent.spans.iter().cloned()); - spans.extend(logical.content.spans.iter().cloned()); - out_lines.push(Line::from(spans).style(logical.line_style)); - joiner_before.push(None); - at_cell_start = false; - continue; - } - - // Prose path: wrap to current width and capture joiners. - // - // `word_wrap_line_with_joiners` guarantees: - // - `wrapped.len() == wrapped_joiners.len()` - // - `wrapped_joiners[0] == None` (first visual segment of a logical line is a hard break) - // - subsequent entries are `Some(joiner)` (soft-wrap continuations). - let opts = RtOptions::new(width as usize) - .initial_indent(compose_indent( - &gutter_first_visual_line, - &logical.initial_indent, - )) - .subsequent_indent(compose_indent( - &gutter_continuation, - &logical.subsequent_indent, - )); - - let (wrapped, wrapped_joiners) = - crate::wrapping::word_wrap_line_with_joiners(&logical.content, opts); - for (visual, joiner) in wrapped.into_iter().zip(wrapped_joiners) { - out_lines.push(line_to_static(&visual).style(logical.line_style)); - joiner_before.push(joiner); - at_cell_start = false; - } - } - - debug_assert_eq!(out_lines.len(), joiner_before.len()); - debug_assert!( - joiner_before - .first() - .is_none_or(std::option::Option::is_none) - ); - - TranscriptLinesWithJoiners { - lines: out_lines, - joiner_before, - } - } - - fn is_stream_continuation(&self) -> bool { - !self.is_first_line - } -} - -#[derive(Debug)] -pub(crate) struct PlainHistoryCell { - lines: Vec>, -} - -impl PlainHistoryCell { - pub(crate) fn new(lines: Vec>) -> Self { - Self { lines } - } -} - -impl HistoryCell for PlainHistoryCell { - fn display_lines(&self, _width: u16) -> Vec> { - self.lines.clone() - } -} - -#[cfg_attr(debug_assertions, allow(dead_code))] -#[derive(Debug)] -pub(crate) struct UpdateAvailableHistoryCell { - latest_version: String, - update_action: Option, -} - -#[cfg_attr(debug_assertions, allow(dead_code))] -impl UpdateAvailableHistoryCell { - pub(crate) fn new(latest_version: String, update_action: Option) -> Self { - Self { - latest_version, - update_action, - } - } -} - -impl HistoryCell for UpdateAvailableHistoryCell { - fn display_lines(&self, width: u16) -> Vec> { - use ratatui_macros::line; - use ratatui_macros::text; - let update_instruction = if let Some(update_action) = self.update_action { - line!["Run ", update_action.command_str().cyan(), " to update."] - } else { - line![ - "See ", - "https://github.com/openai/codex".cyan().underlined(), - " for installation options." - ] - }; - - let content = text![ - line![ - padded_emoji("✨").bold().cyan(), - "Update available!".bold().cyan(), - " ", - format!("{CODEX_CLI_VERSION} -> {}", self.latest_version).bold(), - ], - update_instruction, - "", - "See full release notes:", - "https://github.com/openai/codex/releases/latest" - .cyan() - .underlined(), - ]; - - let inner_width = content - .width() - .min(usize::from(width.saturating_sub(4))) - .max(1); - with_border_with_inner_width(content.lines, inner_width) - } -} - -#[derive(Debug)] -pub(crate) struct PrefixedWrappedHistoryCell { - text: Text<'static>, - initial_prefix: Line<'static>, - subsequent_prefix: Line<'static>, -} - -impl PrefixedWrappedHistoryCell { - pub(crate) fn new( - text: impl Into>, - initial_prefix: impl Into>, - subsequent_prefix: impl Into>, - ) -> Self { - Self { - text: text.into(), - initial_prefix: initial_prefix.into(), - subsequent_prefix: subsequent_prefix.into(), - } - } -} - -impl HistoryCell for PrefixedWrappedHistoryCell { - fn display_lines(&self, width: u16) -> Vec> { - self.transcript_lines_with_joiners(width).lines - } - - fn desired_height(&self, width: u16) -> u16 { - self.display_lines(width).len() as u16 - } - - fn transcript_lines_with_joiners(&self, width: u16) -> TranscriptLinesWithJoiners { - if width == 0 { - return TranscriptLinesWithJoiners { - lines: Vec::new(), - joiner_before: Vec::new(), - }; - } - let opts = RtOptions::new(width.max(1) as usize) - .initial_indent(self.initial_prefix.clone()) - .subsequent_indent(self.subsequent_prefix.clone()); - let (lines, joiner_before) = - crate::wrapping::word_wrap_lines_with_joiners(&self.text, opts); - TranscriptLinesWithJoiners { - lines, - joiner_before, - } - } -} - -fn truncate_exec_snippet(full_cmd: &str) -> String { - let mut snippet = match full_cmd.split_once('\n') { - Some((first, _)) => format!("{first} ..."), - None => full_cmd.to_string(), - }; - snippet = truncate_text(&snippet, 80); - snippet -} - -fn exec_snippet(command: &[String]) -> String { - let full_cmd = strip_bash_lc_and_escape(command); - truncate_exec_snippet(&full_cmd) -} - -pub fn new_approval_decision_cell( - command: Vec, - decision: codex_core::protocol::ReviewDecision, -) -> Box { - use codex_core::protocol::ReviewDecision::*; - - let (symbol, summary): (Span<'static>, Vec>) = match decision { - Approved => { - let snippet = Span::from(exec_snippet(&command)).dim(); - ( - "✔ ".green(), - vec![ - "You ".into(), - "approved".bold(), - " codex to run ".into(), - snippet, - " this time".bold(), - ], - ) - } - ApprovedExecpolicyAmendment { .. } => { - let snippet = Span::from(exec_snippet(&command)).dim(); - ( - "✔ ".green(), - vec![ - "You ".into(), - "approved".bold(), - " codex to run ".into(), - snippet, - " and applied the execpolicy amendment".bold(), - ], - ) - } - ApprovedForSession => { - let snippet = Span::from(exec_snippet(&command)).dim(); - ( - "✔ ".green(), - vec![ - "You ".into(), - "approved".bold(), - " codex to run ".into(), - snippet, - " every time this session".bold(), - ], - ) - } - Denied => { - let snippet = Span::from(exec_snippet(&command)).dim(); - ( - "✗ ".red(), - vec![ - "You ".into(), - "did not approve".bold(), - " codex to run ".into(), - snippet, - ], - ) - } - Abort => { - let snippet = Span::from(exec_snippet(&command)).dim(); - ( - "✗ ".red(), - vec![ - "You ".into(), - "canceled".bold(), - " the request to run ".into(), - snippet, - ], - ) - } - }; - - Box::new(PrefixedWrappedHistoryCell::new( - Line::from(summary), - symbol, - " ", - )) -} - -/// Cyan history cell line showing the current review status. -pub(crate) fn new_review_status_line(message: String) -> PlainHistoryCell { - PlainHistoryCell { - lines: vec![Line::from(message.cyan())], - } -} - -#[derive(Debug)] -pub(crate) struct PatchHistoryCell { - changes: HashMap, - cwd: PathBuf, -} - -impl HistoryCell for PatchHistoryCell { - fn display_lines(&self, width: u16) -> Vec> { - create_diff_summary(&self.changes, &self.cwd, width as usize) - } -} - -#[derive(Debug)] -struct CompletedMcpToolCallWithImageOutput { - _image: DynamicImage, -} -impl HistoryCell for CompletedMcpToolCallWithImageOutput { - fn display_lines(&self, _width: u16) -> Vec> { - vec!["tool result (image output)".into()] - } -} - -pub(crate) const SESSION_HEADER_MAX_INNER_WIDTH: usize = 56; // Just an eyeballed value - -pub(crate) fn card_inner_width(width: u16, max_inner_width: usize) -> Option { - if width < 4 { - return None; - } - let inner_width = std::cmp::min(width.saturating_sub(4) as usize, max_inner_width); - Some(inner_width) -} - -/// Render `lines` inside a border sized to the widest span in the content. -pub(crate) fn with_border(lines: Vec>) -> Vec> { - with_border_internal(lines, None) -} - -/// Render `lines` inside a border whose inner width is at least `inner_width`. -/// -/// This is useful when callers have already clamped their content to a -/// specific width and want the border math centralized here instead of -/// duplicating padding logic in the TUI widgets themselves. -pub(crate) fn with_border_with_inner_width( - lines: Vec>, - inner_width: usize, -) -> Vec> { - with_border_internal(lines, Some(inner_width)) -} - -fn with_border_internal( - lines: Vec>, - forced_inner_width: Option, -) -> Vec> { - let max_line_width = lines - .iter() - .map(|line| { - line.iter() - .map(|span| UnicodeWidthStr::width(span.content.as_ref())) - .sum::() - }) - .max() - .unwrap_or(0); - let content_width = forced_inner_width - .unwrap_or(max_line_width) - .max(max_line_width); - - let mut out = Vec::with_capacity(lines.len() + 2); - let border_inner_width = content_width + 2; - out.push(vec![format!("╭{}╮", "─".repeat(border_inner_width)).dim()].into()); - - for line in lines.into_iter() { - let used_width: usize = line - .iter() - .map(|span| UnicodeWidthStr::width(span.content.as_ref())) - .sum(); - let span_count = line.spans.len(); - let mut spans: Vec> = Vec::with_capacity(span_count + 4); - spans.push(Span::from("│ ").dim()); - spans.extend(line.into_iter()); - if used_width < content_width { - spans.push(Span::from(" ".repeat(content_width - used_width)).dim()); - } - spans.push(Span::from(" │").dim()); - out.push(Line::from(spans)); - } - - out.push(vec![format!("╰{}╯", "─".repeat(border_inner_width)).dim()].into()); - - out -} - -/// Return the emoji followed by a hair space (U+200A). -/// Using only the hair space avoids excessive padding after the emoji while -/// still providing a small visual gap across terminals. -pub(crate) fn padded_emoji(emoji: &str) -> String { - format!("{emoji}\u{200A}") -} - -#[derive(Debug)] -struct TooltipHistoryCell { - tip: String, -} - -impl TooltipHistoryCell { - fn new(tip: String) -> Self { - Self { tip } - } -} - -impl HistoryCell for TooltipHistoryCell { - fn display_lines(&self, width: u16) -> Vec> { - let indent = " "; - let indent_width = UnicodeWidthStr::width(indent); - let wrap_width = usize::from(width.max(1)) - .saturating_sub(indent_width) - .max(1); - let mut lines: Vec> = Vec::new(); - append_markdown( - &format!("**Tip:** {}", self.tip), - Some(wrap_width), - &mut lines, - ); - - prefix_lines(lines, indent.into(), indent.into()) - } -} - -#[derive(Debug)] -pub struct SessionInfoCell(CompositeHistoryCell); - -impl HistoryCell for SessionInfoCell { - fn display_lines(&self, width: u16) -> Vec> { - self.0.display_lines(width) - } - - fn desired_height(&self, width: u16) -> u16 { - self.0.desired_height(width) - } - - fn transcript_lines(&self, width: u16) -> Vec> { - self.0.transcript_lines(width) - } -} - -pub(crate) fn new_session_info( - config: &Config, - requested_model: &str, - event: SessionConfiguredEvent, - is_first_event: bool, - is_collaboration: bool, - collaboration_mode: CollaborationMode, -) -> SessionInfoCell { - let SessionConfiguredEvent { - model, - reasoning_effort, - .. - } = event; - // Header box rendered as history (so it appears at the very top) - let header = SessionHeaderHistoryCell::new( - model.clone(), - Style::default(), - reasoning_effort, - config.cwd.clone(), - CODEX_CLI_VERSION, - is_collaboration, - collaboration_mode, - ); - let mut parts: Vec> = vec![Box::new(header)]; - - if is_first_event { - // Help lines below the header (new copy and list) - let help_lines: Vec> = vec![ - " To get started, describe a task or try one of these commands:" - .dim() - .into(), - Line::from(""), - Line::from(vec![ - " ".into(), - "/init".into(), - " - create an AGENTS.md file with instructions for Codex".dim(), - ]), - Line::from(vec![ - " ".into(), - "/status".into(), - " - show current session configuration".dim(), - ]), - Line::from(vec![ - " ".into(), - "/approvals".into(), - " - choose what Codex can do without approval".dim(), - ]), - Line::from(vec![ - " ".into(), - "/model".into(), - " - choose what model and reasoning effort to use".dim(), - ]), - Line::from(vec![ - " ".into(), - "/review".into(), - " - review any changes and find issues".dim(), - ]), - ]; - - parts.push(Box::new(PlainHistoryCell { lines: help_lines })); - } else { - if config.show_tooltips - && let Some(tooltips) = tooltips::random_tooltip().map(TooltipHistoryCell::new) - { - parts.push(Box::new(tooltips)); - } - if requested_model != model { - let lines = vec![ - "model changed:".magenta().bold().into(), - format!("requested: {requested_model}").into(), - format!("used: {model}").into(), - ]; - parts.push(Box::new(PlainHistoryCell { lines })); - } - } - - SessionInfoCell(CompositeHistoryCell { parts }) -} - -pub(crate) fn new_user_prompt( - message: String, - text_elements: Vec, - local_image_paths: Vec, -) -> UserHistoryCell { - UserHistoryCell { - message, - text_elements, - local_image_paths, - } -} - -#[derive(Debug)] -pub(crate) struct SessionHeaderHistoryCell { - version: &'static str, - model: String, - model_style: Style, - reasoning_effort: Option, - directory: PathBuf, - is_collaboration: bool, - collaboration_mode: CollaborationMode, -} - -impl SessionHeaderHistoryCell { - pub(crate) fn new( - model: String, - model_style: Style, - reasoning_effort: Option, - directory: PathBuf, - version: &'static str, - is_collaboration: bool, - collaboration_mode: CollaborationMode, - ) -> Self { - Self::new_with_style( - model, - model_style, - reasoning_effort, - directory, - version, - is_collaboration, - collaboration_mode, - ) - } - - pub(crate) fn new_with_style( - model: String, - model_style: Style, - reasoning_effort: Option, - directory: PathBuf, - version: &'static str, - is_collaboration: bool, - collaboration_mode: CollaborationMode, - ) -> Self { - Self { - version, - model, - model_style, - reasoning_effort, - directory, - is_collaboration, - collaboration_mode, - } - } - - fn collaboration_mode_label(&self) -> Option<&'static str> { - if !self.is_collaboration { - return None; - } - match &self.collaboration_mode { - CollaborationMode::Plan(_) => Some("Plan"), - CollaborationMode::PairProgramming(_) => Some("Pair Programming"), - CollaborationMode::Execute(_) => Some("Execute"), - CollaborationMode::Custom(_) => None, - } - } - - fn format_directory(&self, max_width: Option) -> String { - Self::format_directory_inner(&self.directory, max_width) - } - - fn format_directory_inner(directory: &Path, max_width: Option) -> String { - let formatted = if let Some(rel) = relativize_to_home(directory) { - if rel.as_os_str().is_empty() { - "~".to_string() - } else { - format!("~{}{}", std::path::MAIN_SEPARATOR, rel.display()) - } - } else { - directory.display().to_string() - }; - - if let Some(max_width) = max_width { - if max_width == 0 { - return String::new(); - } - if UnicodeWidthStr::width(formatted.as_str()) > max_width { - return crate::text_formatting::center_truncate_path(&formatted, max_width); - } - } - - formatted - } - - fn reasoning_label(&self) -> Option<&'static str> { - self.reasoning_effort.map(|effort| match effort { - ReasoningEffortConfig::Minimal => "minimal", - ReasoningEffortConfig::Low => "low", - ReasoningEffortConfig::Medium => "medium", - ReasoningEffortConfig::High => "high", - ReasoningEffortConfig::XHigh => "xhigh", - ReasoningEffortConfig::None => "none", - }) - } -} - -impl HistoryCell for SessionHeaderHistoryCell { - fn display_lines(&self, width: u16) -> Vec> { - let Some(inner_width) = card_inner_width(width, SESSION_HEADER_MAX_INNER_WIDTH) else { - return Vec::new(); - }; - - let make_row = |spans: Vec>| Line::from(spans); - - // Title line rendered inside the box: ">_ OpenAI Codex (vX)" - let title_spans: Vec> = vec![ - Span::from(">_ ").dim(), - Span::from("OpenAI Codex").bold(), - Span::from(" ").dim(), - Span::from(format!("(v{})", self.version)).dim(), - ]; - - const CHANGE_MODEL_HINT_COMMAND: &str = "/model"; - const CHANGE_MODEL_HINT_EXPLANATION: &str = " to change"; - const CHANGE_MODE_HINT_EXPLANATION: &str = " to change mode"; - const DIR_LABEL: &str = "directory:"; - let label_width = DIR_LABEL.len(); - let model_spans: Vec> = if self.is_collaboration { - let collab_label = format!( - "{collab_label: = key_hint::shift(KeyCode::Tab).into(); - spans.push(shift_tab_span.cyan()); - spans.push(CHANGE_MODE_HINT_EXPLANATION.dim()); - spans - } else { - let model_label = format!( - "{model_label:>, -} - -impl CompositeHistoryCell { - pub(crate) fn new(parts: Vec>) -> Self { - Self { parts } - } -} - -impl HistoryCell for CompositeHistoryCell { - fn display_lines(&self, width: u16) -> Vec> { - let mut out: Vec> = Vec::new(); - let mut first = true; - for part in &self.parts { - let mut lines = part.display_lines(width); - if !lines.is_empty() { - if !first { - out.push(Line::from("")); - } - out.append(&mut lines); - first = false; - } - } - out - } -} - -#[derive(Debug)] -pub(crate) struct McpToolCallCell { - call_id: String, - invocation: McpInvocation, - start_time: Instant, - duration: Option, - result: Option>, - animations_enabled: bool, -} - -impl McpToolCallCell { - pub(crate) fn new( - call_id: String, - invocation: McpInvocation, - animations_enabled: bool, - ) -> Self { - Self { - call_id, - invocation, - start_time: Instant::now(), - duration: None, - result: None, - animations_enabled, - } - } - - pub(crate) fn call_id(&self) -> &str { - &self.call_id - } - - pub(crate) fn complete( - &mut self, - duration: Duration, - result: Result, - ) -> Option> { - let image_cell = try_new_completed_mcp_tool_call_with_image_output(&result) - .map(|cell| Box::new(cell) as Box); - self.duration = Some(duration); - self.result = Some(result); - image_cell - } - - fn success(&self) -> Option { - match self.result.as_ref() { - Some(Ok(result)) => Some(!result.is_error.unwrap_or(false)), - Some(Err(_)) => Some(false), - None => None, - } - } - - pub(crate) fn mark_failed(&mut self) { - let elapsed = self.start_time.elapsed(); - self.duration = Some(elapsed); - self.result = Some(Err("interrupted".to_string())); - } - - fn render_content_block(block: &mcp_types::ContentBlock, width: usize) -> String { - match block { - mcp_types::ContentBlock::TextContent(text) => { - format_and_truncate_tool_result(&text.text, TOOL_CALL_MAX_LINES, width) - } - mcp_types::ContentBlock::ImageContent(_) => "".to_string(), - mcp_types::ContentBlock::AudioContent(_) => "